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
Tabstruct 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.
Tags for this post: Programming Gemini GemView Rust Gtk+ Eva