# Using Egui

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.

## What am I building?

I've been enjoying exploring different ideas related to the "small web" recently, and one of those takes is Project Gemini:
=> https://geminiprotocol.net/

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:
=> /blog/2025/06/project-gemini/

But to summarize it:
* Simplicity is good.
* The Gemini text format is simple, and quite nice.
* The protocol itself doesn't buy you any of the claimed benefits.
* Most of the supposed benefits of the Gemini protocol can be gained by just using a simpler browser that's not trying to implement a combination application sandbox and privacy-destroying torment nexus.

So… let's build a simple browser that can serve Gemtext over HTTP(S)!

## Why Egui?

There are lots of options for GUIs in Rust these days. This site has a list that's longer than I realized:
=> https://areweguiyet.com/

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:
* Egui gives pixel-identical rendering on multiple platforms.
* Egui apps can compile into a single binary with just `cargo build`.
* Accessibility support. (Relatedly, nice keyboard navigation by default.)
* Rendered text is selectable
* Dark/Light themes out of the box
* Pretty good documentation.
* The API aims to be simple.

And there's a great live demo of these features here:
=> https://egui.rs

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/

## Things I've Learned

### Immediate Mode

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:

```rust
if ui.button("click me").clicked() { take_action() }
```

The above is just a shortened version of this:

```rust
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

### Hidden Mutability

Egui tries to give you a simple API, with built-in widgets for common UI idioms. As an example:
=> https://docs.rs/egui/latest/egui/containers/scroll_area/struct.ScrollArea.html ScrollArea

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:

```rust
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:
=> https://github.com/NfNitLoop/egemi/commit/5483e6abe99aac463365e57bd90928cd6201741f Second, better solution

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.

### Async Tasks

Egui doesn't have built-in support for async tasks, but it's not too difficult to add your own. The basic pattern is:

* Listen for an event. (ex: User navigates to a new URL)
* Spawn a task handler (thread or async task, your choice)
* At the beginning of your render loop, check if the task is done. If so, update your app state before the render.

"Check if the task is done" can be several options:
* Try to receive an item from a channel
* Check an Arc<Mutex<Option<T>>> for a value.
* Check a JoinHandle
* etc.

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:
=> https://github.com/NfNitLoop/egemi/blob/5483e6abe99aac463365e57bd90928cd6201741f/src/browser/tab.rs#L163 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.

## Upcoming Changes to Egui

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:
=> https://github.com/emilk/egui/pull/5784 PR #5784: [WIP] Render text with Parley

* Loading system fonts.
* Color fonts (color emoji)
* Better rendering of emojis/unicode.
* Better font styling.

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.

## Egui Summary

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!

## Unrelated Learnings

### Markdown is not simple

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.

=> https://nfnitloop.url.lol/markdownw-wat 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...
=> https://spec.commonmark.org/0.29/#html-blocks non-trivial.

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.

