Trying egui: building a Cistercian Clock with Rust GUI

Rodney Lab - Jan 22 - - Dev Community

🖥️ What is egui?

In this post on trying egui, we

  • take a look at what egui is;
  • see why and how you might want to use it; and
  • see an example of it in action, building a Cistercian clock.

The Cistercian clock uses an archaic form for representing numerals, which predates the modern Arabic numerals (1, 2, 3…). I just built the app for fun, rather than any practical application.

egui is an immediate mode Graphical User Interface (GUI) library. It is quite quick to get going on, and you might consider building a GUI app with it if you are just getting going with Rust.

Immediate mode describes the way egui handles updates. The logic for interactions is kept inside the UI code. This is in contrast to retained mode, where a separate model of the UI state is maintained, and you use callback functions to update the user interface. In egui UI code, closures used to display widgets also include code to update the app state, when the user interacts with the app.

egui for Gaming

egui is often a practical choice for game backends; for tweaking game parameters on-screen, while running the game, without having to break into the code. For example, you could tweak the size of a rendered game object or some difficulty parameter.

Immediate Mode Advantages

  • quick to build apps at the expense of increased CPU usage, compared to retained mode;
  • easier implementation of buttons without callbacks; and
  • no need to check the app is in sync with a virtual state model.

egui is inspired by Dear ImGui, from the C++ world. If you are new game development in Rust, and are looking for a Dear ImGui alternative to pair with Bevy or Macroquad, egui be a help.

In this post, we use the egui template to get started quickly. With that running, we add the Cistercian clock code. We don’t take an in-depth look at the Rust code here, so you should be able to follow along, and get the app running even if you are new to Rust.

🦀 Setting up your System for Rust

Skip this if you already have Rust set up on your system.

  1. Install rustup, the program for downloading and updating your local version of the Rust compiler and Rust tooling:
curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh
Enter fullscreen mode Exit fullscreen mode

You will also need to install a C compiler (GCC, or Clang for example). On macOS, run xcode-select --install for a minimal version of Xcode, if you do not already have the full version set up. For full instructions, see the official Install Rust guide.

  1. Check you are up and running:
rustc --version
Enter fullscreen mode Exit fullscreen mode

At the time of writing, the current rustup version is 1.26 and you may have a newer version. This is different to the Rust version. We will see there is a file in the template which pins the Rust version for your egui project.

⚙️ Spinning up the egui Template

To help you get going quickly, there is a starter template. If you have the GitHub CLI tool installed on your system, the easiest way to use the template is to create a new repo from the Terminal:

gh repo create cistercian-clock \\
  --template="emilk/eframe_template" --public
git clone \\
  https://github.com/YOUR-GITHUB-PROFILE/cistercian-clock.git
Enter fullscreen mode Exit fullscreen mode

Here, --public creates a public repo on your GitHub account (you can use --private, instead, if you prefer).

Alternatively, if you are not using gh, log into GitHub and then, open the egui template repo. If you are logged in, you should see a Use this template button towards the top and right of the page. Click the button to create a new repo on your account, using the template, then from your machine, clone your new instance of the repo.

Spinning up the Template

To run the code, jump into the new repo folder, in the Terminal, and use the command:

cargo run
Enter fullscreen mode Exit fullscreen mode

This will be slower the first time, as cargo has to download packages, and then compile the code. Rust compiles incrementally, so later compilations will be quicker, only recompiling anything that has changed.

Once compilation is complete, the app should pop up, and look something like this:

Trying egui: Screenshot shows starter app running, with a light background, and the Light button highlighted, beside a Dark button, at the top of the window.  Below, the skeleton content has a title eframe template with and some sample widgets.

⌚️ Customizing the Template

Following the template instructions, we should update some fields in Cargo.toml and some source files.

Cargo.toml

Cargo.toml manages dependency versions, workspaces, and package metadata in Rust projects. Update it with your own details:

[package]
name = "cistercian_clock"
version = "0.1.0"
authors = ["Your Name <you@example.com>"]
edition = "2021"
rust-version = "1.72"


[dependencies]
chrono = "0.4.31"
egui = "0.24.1"
eframe = { version = "0.24.1", default-features = false, features = [
    "accesskit",     # Make egui comptaible with screen readers. NOTE: adds a lot of dependencies.
    "default_fonts", # Embed the default egui fonts.
    "glow",          # Use the glow rendering backend. Alternative: "wgpu".
    "persistence",   # Enable restoring app state when restarting the app.
] }
image = { version = "0.24.7", default-features = false }
log = "0.4"

# You only need serde if you want app persistence:
serde = { version = "1", features = ["derive"] }
Enter fullscreen mode Exit fullscreen mode

I also added a couple of crates (Rust packages):

  • chrono — for time utility functions
  • image — to decode our custom app logo file

You might notice there are two styles used for adding dependencies. The more verbose one is helpful for managing output package size. As an optimization, some crates come with extra features, which are disabled by default. Others have features enabled by default, which you may not need in your current project. You have to check docs for the crates you use, to see what will work best for your current project. All Rust crates will have docs, and the relevant docs.rs pages are linked, just above, for chrono and image.

main.rs

src/main.rs is the entry point for our app; the code the operating system will first run on executing it. We change TemplateApp to CistercianClockApp in lines 18 and 35:

#![warn(clippy::all, rust_2018_idioms)]
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release

// When compiling natively:
#[cfg(not(target_arch = "wasm32"))]
fn main() -> eframe::Result<()> {
    env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`).

    let native_options = eframe::NativeOptions {
        viewport: egui::ViewportBuilder::default()
            .with_inner_size([400.0, 300.0])
            .with_min_inner_size([300.0, 220.0]),
        ..Default::default()
    };
    eframe::run_native(
        "eframe template",
        native_options,
        Box::new(|cc| Box::new(cistercian_clock::CistercianClockApp::new(cc))),
    )
}
Enter fullscreen mode Exit fullscreen mode

app.rs

src/app.rs contains the main app logic. Change TemplateApp to CistercianClockApp here in lines 4, 12, 22 and 38:

/// We derive Deserialize/Serialize so we can persist app state on shutdown.
#[derive(serde::Deserialize, serde::Serialize)]
#[serde(default)] // if we add new fields, give them default values when deserializing old state
pub struct CistercianClockApp {
    // Example stuff:
    label: String,

    #[serde(skip)] // This how you opt-out of serialization of a field
    value: f32,
}
Enter fullscreen mode Exit fullscreen mode

There is one final change in src/lib.rs, again replacing TemplateApp with CitercianClockApp:

#![warn(clippy::all, rust_2018_idioms)]

mod app;
pub use app::CistercianClockApp;
Enter fullscreen mode Exit fullscreen mode

There are a couple more customizations listed in the temple, for WASM apps that we will skip over here. Save Cargo.toml, src/app.rs, src/lib.rs, and src/main.rs and now run the command:

cargo run
Enter fullscreen mode Exit fullscreen mode

The app should work just as before. The title, and so on, in the app window will still read “eframe template”; we will change those later. Next, let’s continue the customizations, adding our own app logo.

🖼️ Adding a Custom App Logo or Icon in egui

You will need a 256×256 pixel PNG logo for this part. If you already have an SVG logo, see the post on Open Source Favicon Generation & Optimization to convert it to a PNG.

Replace assets/icon-256.png with your PNG icon, then update src/main.rs to use it:

#![warn(clippy::all, rust_2018_idioms)]
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release

use egui::IconData;

// When compiling natively:
#[cfg(not(target_arch = "wasm32"))]
fn main() -> eframe::Result<()> {
    env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`).

    let icon_image = image::open("assets/icon-256.png").unwrap();
    let width = icon_image.width();
    let height = icon_image.height();
    let icon_rgba8 = icon_image.into_rgba8().to_vec();
    let icon_data = IconData {
        rgba: icon_rgba8,
        width,
        height,
    };

    let native_options = eframe::NativeOptions {
        viewport: egui::ViewportBuilder::default()
            .with_inner_size([400.0, 300.0])
            .with_min_inner_size([300.0, 220.0])
            .with_icon(icon_data),
        ..Default::default()
    };
    eframe::run_native(
        "eframe template",
        native_options,
        Box::new(|cc| Box::new(cistercian_clock::CistercianClockApp::new(cc))),
    )
}
Enter fullscreen mode Exit fullscreen mode

In line 4, you see an example of including a dependency in Rust. In line 11 we use the image package (installed earlier) to convert the PNG into the RGBA8 format expected by egui. We could have imported the image similarly to use egui::IconData, as use image::open, then just used open(...) in line 11, though I preferred sticking with image::open (in line 11) to make it clearer where open comes from, limiting ambiguity, since open is a common function name.

Try rerunning your app, and you should see your own logo now!

Before moving on, let’s update the app title (displayed on the window) and the window size, in main.rs:

    let native_options = eframe::NativeOptions {
        viewport: egui::ViewportBuilder::default()
            .with_inner_size([750.0, 600.0])
            .with_min_inner_size([750.0, 600.0])
            .with_icon(icon_data),
        ..Default::default()
    };
    eframe::run_native(
        "Cistercian Clock",
        native_options,
        Box::new(|cc| Box::new(cistercian_clock::CistercianClockApp::new(cc))),
    )
Enter fullscreen mode Exit fullscreen mode

👀 cargo watch

You can make your workflow slightly more efficient by automatically re-compiling and running your egui app each time you save a source file. cargo watch is handy here. You can install it using Cargo:

cargo install cargo-watch
Enter fullscreen mode Exit fullscreen mode

The simplest command is:

cargo watch
Enter fullscreen mode Exit fullscreen mode

That will just run your app after each source file change. I tend to run:

cargo watch -x check -x clippy -x run
Enter fullscreen mode Exit fullscreen mode

Which will check your code compiles, lint it and then run it. You can also throw -x test in there if working in a Test Driven Development workflow.

clippy is the Rust linter. Some people find it annoying, though I learn quite a bit from it. If clippy does not work, you might need to install first (rustup component add clippy).

⌚️ Trying egui: Cistercian Clock Code

We won’t go through the code line-by-line, instead, paste it in, then we will look at a few of the more interesting parts.

Update src/app.rs by copying the complete file from the finished egui Cistercian Clock project repo.

That should all work now, and when the app restarts, you will see the time using Arabic numerals and a Cistercian numbers cheat sheet, in a scrollable window. The light and dark themes have been updated, and the theme switch is now a single toggle button, replacing separating light and dark buttons.

Trying egui: Screenshot shows Cistercian Clock app running, Int he main window, you see the 24-hour clock time as 14:46:04, below that same time represented compactly in colourful lines in Cistercian notation.  Further down a section titled Cistercian Numbers offers a cheat sheet for converting between the two number systems.

🧐 Trying egui: Interesting Lines in the Code

Trying egui: Overriding Themes

To override the default colour theme, I created an entire new theme. This override (lines 14-84 in src/app.rs) specifies the colours to use for text, warning, hyperlinks etc, using RGB values.

egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
    if ui.visuals().dark_mode {
        ctx.set_visuals(dark_mode_override());
    } else {
        ctx.set_visuals(light_mode_override());
    };

  // The top panel is often a good place for a menu bar:

  egui::menu::bar(ui, |ui| {
      // NOTE: no File->Quit on web pages!
      let is_web = cfg!(target_arch = "wasm32");
      if !is_web {
          ui.menu_button("File", |ui| {
              if ui.button("Quit").clicked() {
                  ctx.send_viewport_cmd(egui::ViewportCommand::Close);
              }
          });
          ui.add_space(16.0);
      }

      egui::widgets::global_dark_light_mode_switch(ui);
  });
});
Enter fullscreen mode Exit fullscreen mode

Clicking the toggle button switches the active visuals (code in line 531). egui will act on the changes in the next loop. dark_mode_override() just returns the struct with the dark mode visuals, I could have pasted the struct in place in line 513 (and similarly for the light mode).

Trying egui: Requesting a Repaint

Comment out line 541 , then run the app. The clock now only updates when you interact with the window; moving the mouse pointer, or clicking. This is fine for many apps, but not ideal for a clock.

We need the clock time to update on its own. request_repaint_after helps here. I set it to update every second, and you can go smaller if you require higher precision.

core::time::Duration is a Rust core library struct. new is its initializer, which lets you specify a duration in seconds (first parameter) and nanoseconds (second parameter). The Rust Core Library is smaller than the Rust Standard Library, and includes functionality which can run on more systems (some embedded systems or microchips have core support, but no std support).

Add line 541 back in so the clock updates normally again.

egui::CentralPanel::default().show(ctx, |ui| {
    let colours = if ui.visuals().dark_mode {
        DARK_CISTERCIAN_NUMERAL_COLOURS
    } else {
        LIGHT_CISTERCIAN_NUMERAL_COLOURS
    };
    ui.ctx().request_repaint_after(Duration::new(1, 0));
    // The central panel the region left after adding TopPanel's and SidePanel's
    ui.heading("Cistercian Time");
    ui.add_space(30.0);
});
Enter fullscreen mode Exit fullscreen mode

Trying egui: Adding a Vertical Scroll

We can also add a scrollable area, in egui, with only a few lines of code:

ScrollArea::vertical()
    .auto_shrink(false)
    .scroll_bar_visibility(ScrollBarVisibility::default())
    .show(ui, |ui| {
        ui.heading("Cistercian Numbers");
        ui.add_space(30.0);
        paint_number_row(ui, &colours, 0, 10);
        ui.add_space(30.0);
        for tens in 1..10 {
            paint_number_row(ui, &colours, 10 * tens, (tens + 1) * 10);
            ui.add_space(15.0);
        }
    // ...TRUNCATED
    });
Enter fullscreen mode Exit fullscreen mode

Here, we wrap the scrollable content in a ScrollArea show method closure.

Reach out if anything is not clear, so I can update the article for everyone. Scan the code for more egui features. We are only scratching the surface of what egui can do here. See the Wrapping Up section, further down, for pointers on where to go to learn more.

💿 Building and Installing the App

So far, we have been running the app in debug mode. To build a native app, run:

cargo build --release
Enter fullscreen mode Exit fullscreen mode

This will optimize the app for speed and binary size, so compilation will be a touch slower than previously. The release mode app will be at ./target/release/cistercian_clock. You can run it from there, though it will be more convenient to install it on your system:

cargo install --path .
Enter fullscreen mode Exit fullscreen mode

That should place the app in ~/.cargo/bin or somewhere equivalent on your system (--path in the command above is the path to the app we want to install, so run the command from the project directory). If that folder is included in your path, you will be able to run the installed app just using its name: cistercian_clock.

Updating egui (for later)

This post was written with egui 0.24, which uses Rust 1.72. Later versions might use a newer Rust version. When you update the egui and eframe in Cargo.toml, also update the toolchain.channel field in the rust-toolchain file to the Rust version your new egui release pairs with.

🤨 What are immediate mode alternatives?

At the start, we mentioned that egui uses immediate mode. It makes creating user interfaces quicker, without the need for callbacks to add interactivity. egui is used in games, with integrations for popular gaming engines. Some developers prefer it to creating Electron based apps, Gossip, the portable Nostr client is an example here.

One drawback of immediate mode is that it can make it harder to position content precisely. For example, centring a button horizontally. For precise layouts, staying in the Rust ecosystem, you might want to try Tauri — it uses web technology, allowing you to apply your existing CSS knowledge for pixel perfect layouts. egui also currently falls short in terms of accessibility. AccessKit has improved matters here, though screen reader users may still prefer web content to your egui app, albeit with AccessKit enabled.

🙌🏽 Trying egui: Wrapping Up

In this trying egui post, we saw some key egui features and why you should consider it for your Rust GUI. More specifically, we saw:

  • what immediate mode is;
  • how to create an egui app; and
  • how to add a custom logo or icon to an egui app.

The source code for the complete project is in a Rodney Lab GitHub repo. For far more egui features, see the egui demo app. It includes links to the source code for the various widgets. If you need more specifics on the APIs, then also see the egui docs.

Let me know what you plan to build with egui. Also, if you have already used Dear ImGui, let me know how egui compares.

🙏🏽 Trying egui: Feedback

If you have found this post useful, see links below for further related content on this site. I do hope you learned one new thing from the video. Let me know if there are any ways I can improve on it. I hope you will use the code or starter in your own projects. Be sure to share your work on X, giving me a mention, so I can see what you did. Finally, be sure to let me know ideas for other short videos you would like to see. Read on to find ways to get in touch, further below. If you have found this post useful, even though you can only afford even a tiny contribution, please consider supporting me through Buy me a Coffee.

Finally, feel free to share the post on your social media accounts for all your followers who will find it useful. As well as leaving a comment below, you can get in touch via @askRodney on X (previously Twitter) and also askRodney on Telegram. Also, see further ways to get in touch with Rodney Lab. I post regularly on Deno as well as Search Engine Optimization among other topics. Also, subscribe to the newsletter to keep up-to-date with our latest projects.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .