2025-09-10 06:18PM -0700
I volunteered to give a small talk about my experience using Egui at the San Diego area Rust Meetup this week, and I've procrastinated writing up a talk until the night before, so here it is.
I've been enjoying exploring different ideas related to the "small web" recently, and one of those takes is Project Gemini:
It's basically a minimalist markdown, with which you can build sites that have a feature set between that of gopher and HTML. And, for some reason, also a new network protocol.
I did some rambling about it here:
But to summarize it:
So… let's build a simple browser that can serve Gemtext over HTTP(S)!
There are lots of options for GUIs in Rust these days. This site has a list that's longer than I realized:
I didn't want to go with any of the web-based Rust UIs like Tauri, Leptos, Dioxus, etc., because the point of the browser is to be minimalist. It seemed simpler to start "from scratch" than to try to shoehorn webby things into a web-rendered view and then do extra work to try to make it not do all the things we want to avoid.
So I narrowed it down to a few choices. Then I chose Egui.
Then I ran into some hiccups that made me second-guess my choice of Egui, so I re-evaluated my choices and actually wrote down my evaulation:
https://github.com/NfNitLoop/egemi/blob/main/docs/adr/002_egui.gmi
The top reasons to use Egui:
And there's a great live demo of these features here:
In particular, *text layout* is quite nice in Egui. When rendering a widget, you can just make multiple calls to render a "label", and egui's layout engine will position the text for you following previous labels, and wrapping lines as necessary.
This works pretty well when taking a Gemtext or Markdown document and trying to render it to screen.
I was able to somewhat quickly get the basics working. You can check out the current state of my browser, called "eGemi", here:
https://github.com/nfnitloop/egemi/
Egui docs do a great job of explaining immediate mode, so I'll just quote them:
https://docs.rs/egui/latest/egui/#understanding-immediate-mode
Immediate mode has its roots in gaming, where everything on the screen is painted at the display refresh rate, i.e. at 60+ frames per second. In immediate mode GUIs, the entire interface is laid out and painted at the same high rate.
[...]
[For example, when rendering a button,] There is no button being created and stored somewhere. The only output of [the code] is some colored shapes, and a `Response`
So, instead of responding to events, updating state, and yielding to the API to re-render the UI, you're instead doing all of that all the time for every frame. You generally render a widget to screen and *immediately* check it to see if the previous render of that element generated events that should be handled:
if ui.button("click me").clicked() { take_action() }
The above is just a shortened version of this:
let button = egui::Button::new("click me");
let response = button.ui(ui);
if response.clicked() { take_action() }
Immediate mode has some problems with dynamically sizing elements on screen. Egui supports multi-pass immediate mode to allow a widget to request re-rendering a frame, in case it detects that it had to re-calculate sizes.
For example, when rendering the main "browser bar" for eGemi, I want the location bar to expand to take up remaining space after all navigation and menu buttons have been rendered. In theory, you could accomplish this by combining a left-to-right layout with a right-to-left layout, rendering the location text box last. But in practice, something about the layout never quite worked in base egui. I ended up using a third-party widget called egui_flex that handled this for me, probably using the multi-pass mode I mentioned above behind the scenes:
https://crates.io/crates/egui_flex
Egui tries to give you a simple API, with built-in widgets for common UI idioms. As an example:
A ScrollArea lets you group together several widgets into an area that will get scrollbars if it grows to be too large to render within a given space. You can create one like this:
egui::ScrollArea::vertical().show(ui, |ui| {
// Add a lot of widgets here.
});
But you might notice – we're not keeping the ScrollArea instance between each render. Every render creates a new ScrollArea::vertical(), and calls its show method. So how does the ScrollArea remember its scroll position between each frame render?
Egui has a `Context` that you can access while rendering. In this context, you can store or fetch state by `Id`. This Id is generated for your widget automatically, based on its position in the render tree. So many of the built-in egui widgets that need to save state between renders use this trick.
This sort of magic reminds me of React hooks. Your application state depends on hidden state management and implicit control flow that can break in unexpected ways.
... which is exactly what happened during my development. When eGemi loads a new page, it creates an entirely new widget to render the new page, throwing away all previous state. However, internally, that widget uses a ScrollArea, and that ScrollArea, though new, would retain the same auto-generated ID as the previous ones, so would maintain the scroll state of the previous page.
I "solved" this problem twice in eGemi:
The trick is to add a manually-generated ID to your render hierarchy, which you can control. In my case, I just generate a new ID with each new page load, which results in new IDs for all child widgets. On the next render, they all have no state, so will start with their default states.
Though, it does make me wonder if/when old, unused states ever get cleaned up from memory.
For my own widgets, I just explicitly save them between renders, and have them keep their own state. It's a lot less magic.
Egui doesn't have built-in support for async tasks, but it's not too difficult to add your own. The basic pattern is:
"Check if the task is done" can be several options:
For egui, I chose to use tokio, because it works well with reqwest. Also, async tasks can be easily cancelled. If a user is currently loading one URL, but decides to cancel the operation (usually by clicking a back button or navigating to some other link), it's easy to cancel the task:
Example Cancellation of Tokio Task via its JoinHandle
You probably also want to do any heavy calculations outside of the main thread too. I make sure to parse documents as part of their load from the network. The parsed document is easy to (constantly re)render into egui widgets for every frame with minimal recalculation.
Egui currently uses a library called ab-glyph to render and layout text. This has some limitations, so the maintainer is open to using a new layout system called Parley, which should hopefully unlock some new features:
PR #5784: [WIP] Render text with Parley
Development seems to have stalled for a few months but I'm keeping my fingers crossed that that PR eventually lands, and I'll definitely be updating to make use of it when it does.
While I went into some of the pain points here, Egui overall felt really productive. I was able to quickly put something together, and it works for Mac, Windows, and (probably) Linux.
I definitely recommend giving it a try!
I've been a fan of Markdown for a while but trying to parse it and render it directly to screen (vs. into HTML) has been enlightening. I really don't recommend it! As in, if you want to write a document that is *not* only going to be rendered into a browser, but also to screen, maybe try something else.
Example: What is an "HTML Block"?
For example, if you ask a Markdown parser to parse an "HTML block" for you so that you can stirp unsafe HTML from your markdown document, you may be surprised that your HTML block ... doesn't have an end tag. Or doesn't have a beginning tag!
And the rules for where an HTML block starts/ends are...
Then there are questions about what constitutes a "paragraph", and what spacing a "paragraph" should have when rendered to screen.
In Markdown, paragraphs are separated by 1+ blank lines. But blank lines are not rendered into HTML. So, do two paragraphs have a blank line between them? That's a question left to CSS!
You can try to simulate paragraphs not separated by empty lines, but that involves invisible whitespace at the end of a line. And then, you don't really have two paragraphs but one paragraph containing a line break.
Gemtext simplifies all of that. It's much easier to map plain text onto the screen.