Skip to content

Commit

Permalink
Basic tab strip for the minibrowser (servo#33100)
Browse files Browse the repository at this point in the history
This implements a simple tab system for servoshell:
- The egui part uses the built-in SelectableLabels components and
  display the full tab title on hover.
- WebView structs now hold all the state for each WebView. When we
  need "global" state, we return the focused WebView state, eg.
  for the load status since it's still global in the UI.
- New keyboard shortcut: [Cmd-or-Ctrl]+[W] to close the current tab.
- New keyboard shortcut: [Cmd-or-Ctrl]+[T] to create a new tab.
- The new tab content is loaded from the 'servo:newtab' url using a
  couple of custom protocol handlers.

Signed-off-by: webbeef <[email protected]>
  • Loading branch information
webbeef authored Aug 27, 2024
1 parent a0ff57c commit 1b48bd1
Show file tree
Hide file tree
Showing 14 changed files with 454 additions and 71 deletions.
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions components/shared/net/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,10 @@ impl Response {
}
}

pub fn network_internal_error<T: Into<String>>(msg: T) -> Response {
Self::network_error(NetworkError::Internal(msg.into()))
}

pub fn url(&self) -> Option<&ServoUrl> {
self.url.as_ref()
}
Expand Down
3 changes: 2 additions & 1 deletion ports/servoshell/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,10 @@ libservo = { path = "../../components/servo" }
cfg-if = { workspace = true }
log = { workspace = true }
getopts = { workspace = true }
mime_guess = { workspace = true }
url = { workspace = true }
servo-media = { workspace = true }

tokio = { workspace = true }

[target.'cfg(target_os = "android")'.dependencies]
android_logger = "0.14"
Expand Down
4 changes: 2 additions & 2 deletions ports/servoshell/desktop/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -434,8 +434,8 @@ impl App {
webviews.handle_window_events(embedder_events);

// If the Gamepad API is enabled, handle gamepad events from GilRs.
// Checking for current_url_string should ensure we'll have a valid browsing context.
if pref!(dom.gamepad.enabled) && webviews.current_url_string().is_some() {
// Checking for focused_webview_id should ensure we'll have a valid browsing context.
if pref!(dom.gamepad.enabled) && webviews.focused_webview_id().is_some() {
webviews.handle_gamepad_events();
}

Expand Down
4 changes: 3 additions & 1 deletion ports/servoshell/desktop/embedder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use webxr::glwindow::GlWindowDiscovery;
#[cfg(target_os = "windows")]
use webxr::openxr::OpenXrDiscovery;

use crate::desktop::protocols::urlinfo;
use crate::desktop::protocols::{resource, servo as servo_handler, urlinfo};

pub enum XrDiscovery {
GlWindow(GlWindowDiscovery),
Expand Down Expand Up @@ -61,6 +61,8 @@ impl EmbedderMethods for EmbedderCallbacks {
fn get_protocol_handlers(&self) -> ProtocolRegistry {
let mut registry = ProtocolRegistry::default();
registry.register("urlinfo", urlinfo::UrlInfoProtocolHander::default());
registry.register("servo", servo_handler::ServoProtocolHander::default());
registry.register("resource", resource::ResourceProtocolHander::default());
registry
}
}
68 changes: 59 additions & 9 deletions ports/servoshell/desktop/minibrowser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ use std::num::NonZeroU32;
use std::sync::Arc;
use std::time::Instant;

use egui::text::{CCursor, CCursorRange};
use egui::text_edit::TextEditState;
use egui::{
pos2, CentralPanel, Color32, Frame, Key, Label, Modifiers, PaintCallback, Pos2, TopBottomPanel,
Vec2,
pos2, CentralPanel, Color32, Frame, Key, Label, Modifiers, PaintCallback, Pos2,
SelectableLabel, TopBottomPanel, Vec2,
};
use egui_glow::CallbackFn;
use egui_winit::EventResponse;
Expand Down Expand Up @@ -61,6 +63,15 @@ pub enum MinibrowserEvent {
Reload,
}

fn truncate_with_ellipsis(input: &str, max_length: usize) -> String {
if input.chars().count() > max_length {
let truncated: String = input.chars().take(max_length.saturating_sub(1)).collect();
format!("{}…", truncated)
} else {
input.to_string()
}
}

impl Minibrowser {
pub fn new(
rendering_context: &RenderingContext,
Expand Down Expand Up @@ -216,9 +227,11 @@ impl Minibrowser {
ui.available_size(),
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
let location_id = egui::Id::new("location_input");
let location_field = ui.add_sized(
ui.available_size(),
egui::TextEdit::singleline(&mut *location.borrow_mut()),
egui::TextEdit::singleline(&mut *location.borrow_mut())
.id(location_id),
);

if location_field.changed() {
Expand All @@ -228,6 +241,16 @@ impl Minibrowser {
i.clone().consume_key(Modifiers::COMMAND, Key::L)
}) {
location_field.request_focus();
if let Some(mut state) =
TextEditState::load(ui.ctx(), location_id)
{
// Select the whole input.
state.cursor.set_char_range(Some(CCursorRange::two(
CCursor::new(0),
CCursor::new(location.borrow().len()),
)));
state.store(ui.ctx(), location_id);
}
}
if location_field.lost_focus() &&
ui.input(|i| i.clone().key_pressed(Key::Enter))
Expand All @@ -242,6 +265,36 @@ impl Minibrowser {
});
};

let mut embedder_events = vec![];

// A simple Tab header strip, using egui 'SelectableLabel' elements.
// TODO: Add a way to close a tab eg. with a [x] control.
TopBottomPanel::top("tabs").show(ctx, |ui| {
ui.allocate_ui_with_layout(
ui.available_size(),
egui::Layout::left_to_right(egui::Align::Center),
|ui| {
for (webview_id, webview) in webviews.webviews().into_iter() {
let msg = match (webview.title.clone(), webview.url.clone()) {
(Some(title), _) => title,
(None, Some(url)) => url.to_string(),
_ => "".to_owned(),
};
let tab = ui.add(SelectableLabel::new(
webview.focused,
truncate_with_ellipsis(&msg, 20),
));
let tab = tab.on_hover_ui(|ui| {
ui.label(&msg);
});
if !webview.focused && tab.clicked() {
embedder_events.push(EmbedderEvent::FocusWebView(webview_id));
}
}
},
);
});

// The toolbar height is where the Context’s available rect starts.
// For reasons that are unclear, the TopBottomPanel’s ui cursor exceeds this by one egui
// point, but the Context is correct and the TopBottomPanel is wrong.
Expand All @@ -255,7 +308,6 @@ impl Minibrowser {
let Some(webview) = webviews.get_mut(focused_webview_id) else {
return;
};
let mut embedder_events = vec![];

CentralPanel::default()
.frame(Frame::none())
Expand Down Expand Up @@ -362,9 +414,9 @@ impl Minibrowser {
app_event_queue: &mut Vec<EmbedderEvent>,
) {
for event in self.event_queue.borrow_mut().drain(..) {
let browser_id = browser.focused_webview_id().unwrap();
match event {
MinibrowserEvent::Go => {
let browser_id = browser.webview_id().unwrap();
let location = self.location.borrow();
if let Some(url) = location_bar_input_to_url(&location.clone()) {
app_event_queue.push(EmbedderEvent::LoadUrl(browser_id, url));
Expand All @@ -374,21 +426,19 @@ impl Minibrowser {
}
},
MinibrowserEvent::Back => {
let browser_id = browser.webview_id().unwrap();
app_event_queue.push(EmbedderEvent::Navigation(
browser_id,
TraversalDirection::Back(1),
));
},
MinibrowserEvent::Forward => {
let browser_id = browser.webview_id().unwrap();
app_event_queue.push(EmbedderEvent::Navigation(
browser_id,
TraversalDirection::Forward(1),
));
},
MinibrowserEvent::Reload => {
let browser_id = browser.webview_id().unwrap();
let browser_id = browser.focused_webview_id().unwrap();
app_event_queue.push(EmbedderEvent::Reload(browser_id));
},
}
Expand All @@ -407,7 +457,7 @@ impl Minibrowser {
}

match browser.current_url_string() {
Some(location) if location != self.location.get_mut() => {
Some(location) if location != *self.location.get_mut() => {
self.location = RefCell::new(location.to_owned());
true
},
Expand Down
2 changes: 2 additions & 0 deletions ports/servoshell/desktop/protocols/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */

pub(crate) mod resource;
pub(crate) mod servo;
pub(crate) mod urlinfo;
108 changes: 108 additions & 0 deletions ports/servoshell/desktop/protocols/resource.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */

//! This protocol handler loads files from the <resources_dir_path()>/protocol/resource directory,
//! sanitizing the path to prevent path escape attacks.
//! For security reasons, loads are only allowed if the referrer has a 'resource' or
//! 'servo' scheme.
use std::fs::File;
use std::future::Future;
use std::io::BufReader;
use std::pin::Pin;

use headers::{ContentType, HeaderMapExt};
use net::fetch::methods::{DoneChannel, FetchContext};
use net::filemanager_thread::FILE_CHUNK_SIZE;
use net::protocols::ProtocolHandler;
use net_traits::filemanager_thread::RelativePos;
use net_traits::request::Request;
use net_traits::response::{Response, ResponseBody};
use net_traits::ResourceFetchTiming;
use tokio::sync::mpsc::unbounded_channel;

#[derive(Default)]
pub struct ResourceProtocolHander {}

impl ResourceProtocolHander {
pub fn response_for_path(
request: &mut Request,
done_chan: &mut DoneChannel,
context: &FetchContext,
path: &str,
) -> Pin<Box<dyn Future<Output = Response> + Send>> {
if path.contains("..") || !path.starts_with("/") {
return Box::pin(std::future::ready(Response::network_internal_error(
"Invalid path",
)));
}

let path = if let Some(path) = path.strip_prefix("/") {
path
} else {
return Box::pin(std::future::ready(Response::network_internal_error(
"Invalid path",
)));
};

let file_path = crate::resources::resources_dir_path()
.join("resource_protocol")
.join(path);

if !file_path.exists() || file_path.is_dir() {
return Box::pin(std::future::ready(Response::network_internal_error(
"Invalid path",
)));
}

let response = if let Ok(file) = File::open(file_path.clone()) {
let mut response = Response::new(
request.current_url(),
ResourceFetchTiming::new(request.timing_type()),
);
let reader = BufReader::with_capacity(FILE_CHUNK_SIZE, file);

// Set Content-Type header.
let mime = mime_guess::from_path(file_path).first_or_octet_stream();
response.headers.typed_insert(ContentType::from(mime));

// Setup channel to receive cross-thread messages about the file fetch
// operation.
let (mut done_sender, done_receiver) = unbounded_channel();
*done_chan = Some((done_sender.clone(), done_receiver));

*response.body.lock().unwrap() = ResponseBody::Receiving(vec![]);

context.filemanager.lock().unwrap().fetch_file_in_chunks(
&mut done_sender,
reader,
response.body.clone(),
context.cancellation_listener.clone(),
RelativePos::full_range(),
);

response
} else {
Response::network_internal_error("Opening file failed")
};

Box::pin(std::future::ready(response))
}
}

impl ProtocolHandler for ResourceProtocolHander {
fn load(
&self,
request: &mut Request,
done_chan: &mut DoneChannel,
context: &FetchContext,
) -> Pin<Box<dyn Future<Output = Response> + Send>> {
let url = request.current_url();

// TODO: Check referrer.
// We unexpectedly get `NoReferrer` for all requests from the newtab page.

Self::response_for_path(request, done_chan, context, url.path())
}
}
43 changes: 43 additions & 0 deletions ports/servoshell/desktop/protocols/servo.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */

//! Loads resources using a mapping from well-known shortcuts to resource: urls.
//! Recognized shorcuts:
//! - servo:newtab
use std::future::Future;
use std::pin::Pin;

use net::fetch::methods::{DoneChannel, FetchContext};
use net::protocols::ProtocolHandler;
use net_traits::request::Request;
use net_traits::response::Response;

use crate::desktop::protocols::resource::ResourceProtocolHander;

#[derive(Default)]
pub struct ServoProtocolHander {}

impl ProtocolHandler for ServoProtocolHander {
fn load(
&self,
request: &mut Request,
done_chan: &mut DoneChannel,
context: &FetchContext,
) -> Pin<Box<dyn Future<Output = Response> + Send>> {
let url = request.current_url();

match url.path() {
"newtab" => ResourceProtocolHander::response_for_path(
request,
done_chan,
context,
"/newtab.html",
),
_ => Box::pin(std::future::ready(Response::network_internal_error(
"Invalid shortcut",
))),
}
}
}
Loading

0 comments on commit 1b48bd1

Please sign in to comment.