Handling unsupported protocols in GemView
The Backend
GemView is meant to be a generic and
reusable Gemini browser widget, while Eva
is the browser built to leverage it. GemView can already handle surfing around
geminispace using the gemini protocol, but you never really know what types of
links are going to be found in Geminispace. There's gemini://
links,
gopher://
, finger://
, http://
and the occasional data:
url.
Handling all of those different protocols in Gemview is out of scope, but we need
to account for them somehow.
Up until now, this has largely been handled inside the gemview
crate itself,
but this is probably not ideal. Someone may want to use a GemView widget and have
some choice over how other protocols are opened. For my own use, I want to have a
psuedo-scheme in eva so that I can leverage the fact that it's trivial to write a
gmi document to display things like bookmarks and history. This would be fairly
straightforward if we were always going to open an eva://
url from the address
bar, or from a menu item, but I want to be able to include links in an eva://
page to load other eva://
pages, such as in the case of displaying a page of
all of the bookmarks which share a common tag.
The implementation began by leveraging the fact that GemView is a subclassed
gobject
type, so we can emit custom signals. In this case, we want to emit a
signal whenever a link is clicked on which uses a scheme that GemView does not
know how to handle. We're going to have to override the virtual function signals
and do some magic here, but gtk-rs has us covered. For the sake of the article,
I'm going to skip most of the prelude for subclassing a widget and direct you to
what the gtk4-rs book
has to say on it if you want some background.
Anyway, here's our function.
// imp.rs @59
fn signals() -> &'static [Signal] {
static SIGNALS: Lazy<Vec<Signal>> = Lazy::new(|| {
vec![
Signal::builder(
"page-loaded",
&[String::static_type().into()],
<()>::static_type().into(),
)
.build(),
// several signals omitted for clarity
Signal::builder( // <- this here is the
"request-unsupported-scheme", // important bit
&[String::static_type().into()],
<()>::static_type().into(),
)
.build(),
]
});
SIGNALS.as_ref()
}
Here we've defined a couple of custom signals for our GemView widget to emit. The
first one is emitted whenever a page is successfully loaded, while the second one
is what we want to emit when GemView encounters a url with a scheme that it
doesn't know how to handle. GemView has a number of other custom signals as well,
but I'm not interested in them for the sake of this article. So what's going on
here? What are those other two parameters given to the signal builder? The line
&[String::static_type().into()],
is the payload that is going to be made
available to the application when the signal is emitted by being passed into the
signal handler function, which is our url.
It would be great if we could send an already parsed url here, since we're parsing it in gemview, but we have to work within the bounds of the primitives that glib can pass as a generic
Value
, so we just pass in the url as a string.
The second parameter is the type of the return value that the signal handler function should return. In our case, we aren't going to return any value from the closure, so we give it the unit type.
Next, we want to emit the signal at the appropriate time. When we begin a page
load, the absolute_url
function is called to check if we have an absolute url
or a relative path. If it's a relative path, we append it to the current url.
Since we're already calling this function whenever we load a page, this is a
great place to insert our check and emit the signal.
// lib.rs @443
fn absolute_url(&self, url: &str) -> Result<String, Box<dyn std::error::Error>> {
match url::Url::parse(url) {
Ok(u) => {
match u.scheme() {
"gemini" | "mercury" => Ok(url.to_string()),
s => {
self.emit_by_name::<()>("request-unsupported-scheme", &[&url.to_string()]);
// the rest of the function is omitted
Moving along swimmingly so far. But if you read the book, you'll notice that the generic way to connect to a signal doesn't really match the convention in the rest of the gtk-rs interface.
// How the book suggests
button.connect_closure(
"max-number-reached",
false,
closure_local!(move |_button: CustomButton, number: i32| {
println!("The maximum number {} has been reached", number);
}),
);
// A typical function to connect to a closure
button.connect_clicked(move => |_| {
println!("Button was clicked");
}
It's plain to see that the generic way is both more verbose and more confusing. Also, attempting to actually do it that way resulted in a whole lot of compilation errors, and I eventually had to find a way to do it by looking at the fractal-next source code. Really though, GemView should follow convention as much as possible, so we're going to create a function called to handle this just like any other widget.
// lib.rs @590
/// Connects to the "request-unsupported-scheme" signal, emitted when the
/// browser has had a request to load a page with an unsupported scheme
pub fn connect_request_unsupported_scheme<F: Fn(&Self, String) + 'static>(
&self,
f: F,
) -> glib::SignalHandlerId {
self.connect_local("request-unsupported-scheme", true, move |values| {
let obj = values[0].get::<Self>().unwrap();
let uri = values[1].get::<String>().unwrap();
f(&obj, uri);
None
})
}
So that basically handles it. We've removed any code to deal with protocols that GemView does not speak, while giving the client a way to handle the url itself. On to Eva, to see what we can do with this.
The client side
In Eva, our main window and associated widgets are referenced via an Rc<Gui>
where the Gui
is a struct containing references to the various widgets.
Similarly, for each tab we have a Tab
struct, which allows us to keep a
reference to the viewer (the GemView widget) and the tab label, among other
things. So when we go to create a new tab, we call the Gui::new_tab()
method.
Note: There's a bunch of other stuff in the
Tab
struct as well. It includes the forward and back buttons, refresh button, address bar, bookmarks button and bookmark editor. This is actually by design, both asthetically and for code simplicity. With all of those things tied to the tab that they're in, the code becomes simpler as they only have to worry about state in relation to one GemView widget. Otherwise, if we only had one version of each of those objects, then we'd have to update their state every time we switched tabs.
// gui/mod.rs @294
fn new_tab(&self, uri: Option<&str>) {
Then, a little further down where we're doing some initialisation we can connect our signal handlers.
// gui/mod.rs @373
let t = newtab.clone();
newtab.viewer().connect_request_unsupported_scheme(move |_, uri| {
if let Some((scheme,_)) = uri.split_once(":") {
match scheme {
"eva" => t.request_eva_page(&uri),
"http" | "https" => if let Err(e) = webbrowser::open(&uri) {
eprintln!("Error opening page in browser: {}", e);
},
s => eprintln!("Unsupported scheme: {}", s),
}
}
});
So we've connected to our custom signal, and if you take a look at the code which
runs in that closure, we're calling a method on that Tab
. We also handle http
url's here by passing them off to the default browser on the system, which is
how we're likely going to be handling mailto:
links in the future. What's cool
is that we can spell out right here what we want to do with any type of link.
So instead of supporting Gopher and Finger (and possibly
titan in GemView I'm considering doing so
in Eva instead.
This also gives us a path to supporting data:
url's in the future, if that
catches on. Read up on what Skyjake has been doing in this space with Lagrange
here.
Let's take a quick peak at the request_eva_page
function (and don't hate on me,
I'm going to go through this with clippy soon enough and find out all about
how my nested matches suck...)
// gui/tab/mod.rs @349
pub fn request_eva_page(&self, uri: &str) {
if let Ok(url) = Url::try_from(uri) {
match url.authority.host.as_str() {
"bookmarks" => {
match url.path {
None => self.open_bookmarks(),
Some(p) if p.raw_path == "/" => self.open_bookmarks(),
Some(p) if p.raw_path == "/tags" ||
p.raw_path == "/tags/" => self.open_bookmark_tags(),
Some(p) => {
let maybe_tag = p.raw_path.replace("/tags/", "");
let bookmarks = BOOKMARKS.lock().unwrap();
if let Some(page) = bookmarks.tag_to_gmi(&maybe_tag) {
self.viewer.render_gmi(&page);
self.viewer.set_uri(uri);
self.addr_bar.set_text("uri");
}
}
}
},
"history" => {
},
_ => {},
}
}
}
So we're doing some simple pattern matching here to parse the actual url, and we're going to generate three different page types. One is an overview showing all of the bookmarks, while the second (eva://bookmarks/tags) is just a list of links to pages which break it all down by tags.
And just to illustrate exactly how simple it is to turn a struct into gemtext
let's look at one of the functions called from there.
// bookmarks/mod.rs @134
pub fn to_gmi(&self) -> String {
let mut page = String::from("# Bookmarks\n\n=> eva://bookmarks/tags Tags\n\n");
for (_, bookmark) in &self.all {
page.push_str(&format!(
"### Name: {}\nDescription:\n> {}\nTags: {}\n=> {}\n",
&bookmark.name,
match &bookmark.description {
Some(d) => &d,
None => "none",
},
&bookmark.tags.join(", "),
&bookmark.url,
));
}
page
}
The Results: Eva's Bookmark Page
Some Takeaways
GemView and Eva are not my first Gtk project, and not my fisr Gtk-rs project either for that matter. I'm also actively building and maintaining both Gfret and Zterm. Neither one of those projects uses subclassing to achieve it's functionality, although I can think of a few things in Zterm that would have been much easier had it been an available option.
Zterm is written in Zig and uses Gtk3. The bindings are currently hand generated and are another project of mine. Contributions would be very welcome..
When should you subclass? The projects that are highlighted on
gtk-rs.org all seem to rely quite heavily on it, including
composite widgets which I haven't even touched on here. However, there is quite a
bit that can be accomplished without going to the bother of subclassing. For
instance, I could very well have made each Tab
in Eva a composite widget, and
there would definitely have been some benefits. The fact that it works as well as
it does shows that it (subclassing) wasn't strictly speaking necessary. The
reason that I went ahead and did it for GemView, on the other hand, is that there
is really no other way that it could have been split out into a separate crate
and be usable as a general purpose Gemini browser widget.
So along with our custom signal and handlers described above, there's actually a number of features in Eva that rely on GemView specific signals, along with a couple of other features that you get access to when you subclass a widget. GemView keeps a fair amount of state internally, as an integrated part of the widget. We have a number of font descriptions that get stored, the current url, a forward list and a back list. As an example of how this is useful, when a page has successfully loaded, the "page-loaded" signal is emitted by GemView. The signal handler in Eva connects to that signal and sets the text in the address bar, the tab label, and the window title accordingly. It also sets the forward and backward buttons to sensitive or insensitive depending on whether there are actually any items to jump forward or back to. These are nice little touches, and from what I've seen they get largely glossed over in the other Gtk based Gemini clients.
Long story short, subclassing provides us with state that is associated with our widget and the ability to create and connect to our own custom signals. It can seem somewhat daunting and is kind of sparsely documented, which was part of why I decided to share some insight here after taking the plunge. I hope that it will be useful to someone.
I am very much endebted to Julien Hofer for the excellent book Gui development with Rust and GTK 4 and to the Fractal-next project, which filled in a number of gaps for me via reading it's source code. As always, you can get in touch with me for comments and questions via Mastodon, and I am very open to issues, feature requests and code contribution on Codeberg.
2022-02-18