1use std::collections::HashMap;
6use std::rc::Rc;
7use std::sync::Arc;
8
9use dpi::PhysicalSize;
10use egui::text::{CCursor, CCursorRange};
11use egui::text_edit::TextEditState;
12use egui::{
13 Button, Key, Label, LayerId, Modifiers, PaintCallback, TopBottomPanel, Vec2, WidgetInfo,
14 WidgetType, pos2,
15};
16use egui_glow::{CallbackFn, EguiGlow};
17use egui_winit::EventResponse;
18use euclid::{Box2D, Length, Point2D, Rect, Scale, Size2D};
19use log::warn;
20use servo::{
21 DeviceIndependentPixel, DevicePixel, Image, LoadStatus, OffscreenRenderingContext, PixelFormat,
22 RenderingContext, WebView, WebViewId,
23};
24use url::Url;
25use winit::event::{ElementState, MouseButton, WindowEvent};
26use winit::event_loop::{ActiveEventLoop, EventLoopProxy};
27use winit::window::Window;
28
29use super::geometry::winit_position_to_euclid_point;
30use crate::desktop::event_loop::AppEvent;
31use crate::desktop::headed_window;
32use crate::running_app_state::{RunningAppState, UserInterfaceCommand};
33use crate::window::ServoShellWindow;
34
35pub struct Gui {
38 rendering_context: Rc<OffscreenRenderingContext>,
39 context: EguiGlow,
40 event_queue: Vec<UserInterfaceCommand>,
41 toolbar_height: Length<f32, DeviceIndependentPixel>,
42
43 last_mouse_position: Option<Point2D<f32, DeviceIndependentPixel>>,
44 location: String,
45
46 location_dirty: bool,
48
49 load_status: LoadStatus,
51
52 status_text: Option<String>,
54
55 can_go_back: bool,
57
58 can_go_forward: bool,
60
61 favicon_textures: HashMap<WebViewId, (egui::TextureHandle, egui::load::SizedTexture)>,
65}
66
67fn truncate_with_ellipsis(input: &str, max_length: usize) -> String {
68 if input.chars().count() > max_length {
69 let truncated: String = input.chars().take(max_length.saturating_sub(1)).collect();
70 format!("{}…", truncated)
71 } else {
72 input.to_string()
73 }
74}
75
76impl Drop for Gui {
77 fn drop(&mut self) {
78 self.rendering_context
79 .make_current()
80 .expect("Could not make window RenderingContext current");
81 self.context.destroy();
82 }
83}
84
85impl Gui {
86 pub(crate) fn new(
87 winit_window: &Window,
88 event_loop: &ActiveEventLoop,
89 event_loop_proxy: EventLoopProxy<AppEvent>,
90 rendering_context: Rc<OffscreenRenderingContext>,
91 initial_url: Url,
92 ) -> Self {
93 rendering_context
94 .make_current()
95 .expect("Could not make window RenderingContext current");
96 let mut context = EguiGlow::new(
97 event_loop,
98 rendering_context.glow_gl_api(),
99 None,
100 None,
101 false,
102 );
103
104 context
105 .egui_winit
106 .init_accesskit(event_loop, winit_window, event_loop_proxy);
107 winit_window.set_visible(true);
108
109 context.egui_ctx.options_mut(|options| {
110 options.zoom_with_keyboard = false;
113
114 options.fallback_theme = egui::Theme::Light;
117 });
118
119 Self {
120 rendering_context,
121 context,
122 event_queue: vec![],
123 toolbar_height: Default::default(),
124 last_mouse_position: None,
125 location: initial_url.to_string(),
126 location_dirty: false,
127 load_status: LoadStatus::Complete,
128 status_text: None,
129 can_go_back: false,
130 can_go_forward: false,
131 favicon_textures: Default::default(),
132 }
133 }
134
135 pub(crate) fn take_user_interface_commands(&mut self) -> Vec<UserInterfaceCommand> {
136 std::mem::take(&mut self.event_queue)
137 }
138
139 pub(crate) fn on_window_event(
140 &mut self,
141 winit_window: &Window,
142 event: &WindowEvent,
143 ) -> EventResponse {
144 let mut result = self.context.on_window_event(winit_window, event);
145 result.consumed &= match event {
146 WindowEvent::CursorMoved { position, .. } => {
147 let scale = Scale::<_, DeviceIndependentPixel, _>::new(
148 self.context.egui_ctx.pixels_per_point(),
149 );
150 self.last_mouse_position =
151 Some(winit_position_to_euclid_point(*position).to_f32() / scale);
152 self.last_mouse_position
153 .is_some_and(|p| self.is_in_egui_toolbar_rect(p))
154 },
155 WindowEvent::MouseInput {
156 state: ElementState::Pressed,
157 button: MouseButton::Forward,
158 ..
159 } => {
160 self.event_queue.push(UserInterfaceCommand::Forward);
161 true
162 },
163 WindowEvent::MouseInput {
164 state: ElementState::Pressed,
165 button: MouseButton::Back,
166 ..
167 } => {
168 self.event_queue.push(UserInterfaceCommand::Back);
169 true
170 },
171 WindowEvent::MouseWheel { .. } | WindowEvent::MouseInput { .. } => self
172 .last_mouse_position
173 .is_some_and(|p| self.is_in_egui_toolbar_rect(p)),
174 _ => true,
175 };
176 result
177 }
178
179 pub(crate) fn toolbar_height(&self) -> Length<f32, DeviceIndependentPixel> {
182 self.toolbar_height
183 }
184
185 fn is_in_egui_toolbar_rect(&self, position: Point2D<f32, DeviceIndependentPixel>) -> bool {
187 position.y < self.toolbar_height.get()
188 }
189
190 fn toolbar_button(text: &str) -> egui::Button<'_> {
192 egui::Button::new(text)
193 .frame(false)
194 .min_size(Vec2 { x: 20.0, y: 20.0 })
195 }
196
197 fn browser_tab(
201 ui: &mut egui::Ui,
202 window: &ServoShellWindow,
203 webview: WebView,
204 event_queue: &mut Vec<UserInterfaceCommand>,
205 favicon_texture: Option<egui::load::SizedTexture>,
206 ) {
207 let label = match (webview.page_title(), webview.url()) {
208 (Some(title), _) if !title.is_empty() => title,
209 (_, Some(url)) => url.to_string(),
210 _ => "New Tab".into(),
211 };
212
213 let inactive_bg_color = ui.visuals().window_fill;
214 let active_bg_color = ui.visuals().widgets.active.weak_bg_fill;
215 let active = window.active_webview().map(|webview| webview.id()) == Some(webview.id());
216
217 let mut tab_frame = egui::Frame::NONE.corner_radius(4).begin(ui);
219 {
220 tab_frame.content_ui.add_space(5.0);
221
222 let visuals = tab_frame.content_ui.visuals_mut();
223 visuals.widgets.active.bg_stroke.width = 0.0;
225 visuals.widgets.hovered.bg_stroke.width = 0.0;
226 visuals.widgets.noninteractive.weak_bg_fill = inactive_bg_color;
229 visuals.widgets.inactive.weak_bg_fill = inactive_bg_color;
230 visuals.widgets.hovered.weak_bg_fill = active_bg_color;
231 visuals.widgets.active.weak_bg_fill = active_bg_color;
232 visuals.selection.bg_fill = active_bg_color;
233 visuals.selection.stroke.color = visuals.widgets.active.fg_stroke.color;
234 visuals.widgets.hovered.fg_stroke.color = visuals.widgets.active.fg_stroke.color;
235
236 visuals.widgets.active.expansion = 0.0;
238 visuals.widgets.hovered.expansion = 0.0;
239
240 if let Some(favicon) = favicon_texture {
241 tab_frame.content_ui.add(
242 egui::Image::from_texture(favicon)
243 .fit_to_exact_size(egui::vec2(16.0, 16.0))
244 .bg_fill(egui::Color32::TRANSPARENT),
245 );
246 }
247
248 let tab = tab_frame
249 .content_ui
250 .add(Button::selectable(
251 active,
252 truncate_with_ellipsis(&label, 20),
253 ))
254 .on_hover_ui(|ui| {
255 ui.label(&label);
256 });
257
258 let close_button = tab_frame
259 .content_ui
260 .add(egui::Button::new("X").fill(egui::Color32::TRANSPARENT));
261 close_button.widget_info(|| {
262 let mut info = WidgetInfo::new(WidgetType::Button);
263 info.label = Some("Close".into());
264 info
265 });
266 if close_button.clicked() || close_button.middle_clicked() || tab.middle_clicked() {
267 event_queue.push(UserInterfaceCommand::CloseWebView(webview.id()))
268 } else if !active && tab.clicked() {
269 window.activate_webview(webview.id());
270 }
271 }
272
273 let response = tab_frame.allocate_space(ui);
274 let fill_color = if active || 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(crate) fn update(
285 &mut self,
286 state: &RunningAppState,
287 window: &ServoShellWindow,
288 headed_window: &headed_window::Window,
289 ) {
290 self.rendering_context
291 .make_current()
292 .expect("Could not make RenderingContext current");
293 let Self {
294 rendering_context,
295 context,
296 event_queue,
297 toolbar_height,
298 location,
299 location_dirty,
300 favicon_textures,
301 ..
302 } = self;
303
304 let winit_window = headed_window.winit_window();
305 context.run(winit_window, |ctx| {
306 load_pending_favicons(ctx, window, favicon_textures);
307
308 if winit_window.fullscreen().is_none() {
311 let frame = egui::Frame::default()
312 .fill(ctx.style().visuals.window_fill)
313 .inner_margin(4.0);
314 TopBottomPanel::top("toolbar").frame(frame).show(ctx, |ui| {
315 ui.allocate_ui_with_layout(
316 ui.available_size(),
317 egui::Layout::left_to_right(egui::Align::Center),
318 |ui| {
319 let back_button =
320 ui.add_enabled(self.can_go_back, Gui::toolbar_button("⏴"));
321 back_button.widget_info(|| {
322 let mut info = WidgetInfo::new(WidgetType::Button);
323 info.label = Some("Back".into());
324 info
325 });
326 if back_button.clicked() {
327 *location_dirty = false;
328 event_queue.push(UserInterfaceCommand::Back);
329 }
330
331 let forward_button =
332 ui.add_enabled(self.can_go_forward, Gui::toolbar_button("⏵"));
333 forward_button.widget_info(|| {
334 let mut info = WidgetInfo::new(WidgetType::Button);
335 info.label = Some("Forward".into());
336 info
337 });
338 if forward_button.clicked() {
339 *location_dirty = false;
340 event_queue.push(UserInterfaceCommand::Forward);
341 }
342
343 match self.load_status {
344 LoadStatus::Started | LoadStatus::HeadParsed => {
345 let stop_button = ui.add(Gui::toolbar_button("X"));
346 stop_button.widget_info(|| {
347 let mut info = WidgetInfo::new(WidgetType::Button);
348 info.label = Some("Stop".into());
349 info
350 });
351 if stop_button.clicked() {
352 warn!("Do not support stop yet.");
353 }
354 },
355 LoadStatus::Complete => {
356 let reload_button = ui.add(Gui::toolbar_button("↻"));
357 reload_button.widget_info(|| {
358 let mut info = WidgetInfo::new(WidgetType::Button);
359 info.label = Some("Reload".into());
360 info
361 });
362 if reload_button.clicked() {
363 *location_dirty = false;
364 event_queue.push(UserInterfaceCommand::Reload);
365 }
366 },
367 }
368 ui.add_space(2.0);
369
370 ui.allocate_ui_with_layout(
371 ui.available_size(),
372 egui::Layout::right_to_left(egui::Align::Center),
373 |ui| {
374 let mut experimental_preferences_enabled =
375 state.experimental_preferences_enabled();
376 let prefs_toggle = ui
377 .toggle_value(&mut experimental_preferences_enabled, "☢")
378 .on_hover_text("Enable experimental prefs");
379 prefs_toggle.widget_info(|| {
380 let mut info = WidgetInfo::new(WidgetType::Button);
381 info.label = Some("Enable experimental preferences".into());
382 info.selected = Some(experimental_preferences_enabled);
383 info
384 });
385 if prefs_toggle.clicked() {
386 state.set_experimental_preferences_enabled(
387 experimental_preferences_enabled,
388 );
389 *location_dirty = false;
390 event_queue.push(UserInterfaceCommand::ReloadAll);
391 }
392
393 let location_id = egui::Id::new("location_input");
394 let location_field = ui.add_sized(
395 ui.available_size(),
396 egui::TextEdit::singleline(location)
397 .id(location_id)
398 .hint_text("Search or enter address"),
399 );
400
401 if location_field.changed() {
402 *location_dirty = true;
403 }
404 if ui.input(|i| {
406 if cfg!(target_os = "macos") {
407 i.clone().consume_key(Modifiers::COMMAND, Key::L)
408 } else {
409 i.clone().consume_key(Modifiers::COMMAND, Key::L) ||
410 i.clone().consume_key(Modifiers::ALT, Key::D)
411 }
412 }) {
413 location_field.request_focus();
415 }
416 if location_field.gained_focus() {
418 if let Some(mut state) =
419 TextEditState::load(ui.ctx(), location_id)
420 {
421 state.cursor.set_char_range(Some(CCursorRange::two(
423 CCursor::new(0),
424 CCursor::new(location.len()),
425 )));
426 state.store(ui.ctx(), location_id);
427 }
428 }
429 if location_field.lost_focus() &&
431 ui.input(|i| i.clone().key_pressed(Key::Enter))
432 {
433 event_queue
434 .push(UserInterfaceCommand::Go(location.clone()));
435 }
436 },
437 );
438 },
439 );
440 });
441
442 TopBottomPanel::top("tabs").show(ctx, |ui| {
444 ui.allocate_ui_with_layout(
445 ui.available_size(),
446 egui::Layout::left_to_right(egui::Align::Center),
447 |ui| {
448 for (id, webview) in window.webviews().into_iter() {
449 let favicon = favicon_textures
450 .get(&id)
451 .map(|(_, favicon)| favicon)
452 .copied();
453 Self::browser_tab(ui, window, webview, event_queue, favicon);
454 }
455
456 let new_tab_button = ui.add(Gui::toolbar_button("+"));
457 new_tab_button.widget_info(|| {
458 let mut info = WidgetInfo::new(WidgetType::Button);
459 info.label = Some("New tab".into());
460 info
461 });
462 if new_tab_button.clicked() {
463 event_queue.push(UserInterfaceCommand::NewWebView);
464 }
465
466 let new_window_button = ui.add(Gui::toolbar_button("⊞"));
467 new_window_button.widget_info(|| {
468 let mut info = WidgetInfo::new(WidgetType::Button);
469 info.label = Some("New window".into());
470 info
471 });
472 if new_window_button.clicked() {
473 event_queue.push(UserInterfaceCommand::NewWindow);
474 }
475 },
476 );
477 });
478 };
479
480 *toolbar_height = Length::new(ctx.available_rect().min.y);
484
485 let scale =
486 Scale::<_, DeviceIndependentPixel, DevicePixel>::new(ctx.pixels_per_point());
487
488 headed_window.for_each_active_dialog(window, |dialog| dialog.update(ctx));
489
490 let rect = ctx.available_rect();
493 let size = Size2D::new(rect.width(), rect.height()) * scale;
494 let rect = Box2D::from_origin_and_size(Point2D::origin(), size);
495 if let Some(webview) = window.active_webview() &&
496 rect != webview.rect()
497 {
498 webview.resize(PhysicalSize::new(size.width as u32, size.height as u32))
502 }
503
504 if let Some(status_text) = &self.status_text {
505 egui::Tooltip::always_open(
506 ctx.clone(),
507 LayerId::background(),
508 "tooltip layer".into(),
509 pos2(0.0, ctx.available_rect().max.y),
510 )
511 .show(|ui| ui.add(Label::new(status_text.clone()).extend()));
512 }
513
514 window.repaint_webviews();
515
516 if let Some(render_to_parent) = rendering_context.render_to_parent_callback() {
517 ctx.layer_painter(LayerId::background()).add(PaintCallback {
518 rect: ctx.available_rect(),
519 callback: Arc::new(CallbackFn::new(move |info, painter| {
520 let clip = info.viewport_in_pixels();
521 let rect_in_parent = Rect::new(
522 Point2D::new(clip.left_px, clip.from_bottom_px),
523 Size2D::new(clip.width_px, clip.height_px),
524 );
525 render_to_parent(painter.gl(), rect_in_parent)
526 })),
527 });
528 }
529 });
530 }
531
532 pub(crate) fn paint(&mut self, window: &Window) {
534 self.rendering_context
535 .make_current()
536 .expect("Could not make RenderingContext current");
537 self.rendering_context
538 .parent_context()
539 .prepare_for_rendering();
540 self.context.paint(window);
541 self.rendering_context.parent_context().present();
542 }
543
544 fn update_location_in_toolbar(&mut self, window: &ServoShellWindow) -> bool {
547 if self.location_dirty {
549 return false;
550 }
551
552 let current_url_string = window
553 .active_webview()
554 .and_then(|webview| Some(webview.url()?.to_string()));
555 match current_url_string {
556 Some(location) if location != self.location => {
557 self.location = location.to_owned();
558 true
559 },
560 _ => false,
561 }
562 }
563
564 fn update_load_status(&mut self, window: &ServoShellWindow) -> bool {
565 let state_status = window
566 .active_webview()
567 .map(|webview| webview.load_status())
568 .unwrap_or(LoadStatus::Complete);
569 let old_status = std::mem::replace(&mut self.load_status, state_status);
570 let status_changed = old_status != self.load_status;
571
572 if status_changed {
575 self.location_dirty = false;
576 }
577
578 status_changed
579 }
580
581 fn update_status_text(&mut self, window: &ServoShellWindow) -> bool {
582 let state_status = window
583 .active_webview()
584 .and_then(|webview| webview.status_text());
585 let old_status = std::mem::replace(&mut self.status_text, state_status);
586 old_status != self.status_text
587 }
588
589 fn update_can_go_back_and_forward(&mut self, window: &ServoShellWindow) -> bool {
590 let (can_go_back, can_go_forward) = window
591 .active_webview()
592 .map(|webview| (webview.can_go_back(), webview.can_go_forward()))
593 .unwrap_or((false, false));
594 let old_can_go_back = std::mem::replace(&mut self.can_go_back, can_go_back);
595 let old_can_go_forward = std::mem::replace(&mut self.can_go_forward, can_go_forward);
596 old_can_go_back != self.can_go_back || old_can_go_forward != self.can_go_forward
597 }
598
599 pub(crate) fn update_webview_data(&mut self, window: &ServoShellWindow) -> bool {
602 self.update_load_status(window) |
607 self.update_location_in_toolbar(window) |
608 self.update_status_text(window) |
609 self.update_can_go_back_and_forward(window)
610 }
611
612 pub(crate) fn handle_accesskit_event(
614 &mut self,
615 event: &egui_winit::accesskit_winit::WindowEvent,
616 ) -> bool {
617 match event {
618 egui_winit::accesskit_winit::WindowEvent::InitialTreeRequested => {
619 self.context.egui_ctx.enable_accesskit();
620 true
621 },
622 egui_winit::accesskit_winit::WindowEvent::ActionRequested(req) => {
623 self.context
624 .egui_winit
625 .on_accesskit_action_request(req.clone());
626 true
627 },
628 egui_winit::accesskit_winit::WindowEvent::AccessibilityDeactivated => {
629 self.context.egui_ctx.disable_accesskit();
630 false
631 },
632 }
633 }
634
635 pub(crate) fn set_zoom_factor(&self, factor: f32) {
636 self.context.egui_ctx.set_zoom_factor(factor);
637 }
638}
639
640fn embedder_image_to_egui_image(image: &Image) -> egui::ColorImage {
641 let width = image.width as usize;
642 let height = image.height as usize;
643
644 match image.format {
645 PixelFormat::K8 => egui::ColorImage::from_gray([width, height], image.data()),
646 PixelFormat::KA8 => {
647 let data: Vec<u8> = image
649 .data()
650 .chunks_exact(2)
651 .flat_map(|pixel| [pixel[0], pixel[0], pixel[0], pixel[1]])
652 .collect();
653 egui::ColorImage::from_rgba_unmultiplied([width, height], &data)
654 },
655 PixelFormat::RGB8 => egui::ColorImage::from_rgb([width, height], image.data()),
656 PixelFormat::RGBA8 => {
657 egui::ColorImage::from_rgba_unmultiplied([width, height], image.data())
658 },
659 PixelFormat::BGRA8 => {
660 let data: Vec<u8> = image
662 .data()
663 .chunks_exact(4)
664 .flat_map(|chunk| [chunk[2], chunk[1], chunk[0], chunk[3]])
665 .collect();
666 egui::ColorImage::from_rgba_unmultiplied([width, height], &data)
667 },
668 }
669}
670
671fn load_pending_favicons(
673 ctx: &egui::Context,
674 window: &ServoShellWindow,
675 texture_cache: &mut HashMap<WebViewId, (egui::TextureHandle, egui::load::SizedTexture)>,
676) {
677 for id in window.take_pending_favicon_loads() {
678 let Some(webview) = window.webview_by_id(id) else {
679 continue;
680 };
681 let Some(favicon) = webview.favicon() else {
682 continue;
683 };
684
685 let egui_image = embedder_image_to_egui_image(&favicon);
686 let handle = ctx.load_texture(format!("favicon-{id:?}"), egui_image, Default::default());
687 let texture = egui::load::SizedTexture::new(
688 handle.id(),
689 egui::vec2(favicon.width as f32, favicon.height as f32),
690 );
691
692 texture_cache.insert(id, (handle, texture));
695 }
696}