🖥️ 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.
Please enable JavaScript to watch the video 📼
🦀 Setting up your System for Rust #
Skip this if you already have Rust set up on your system.
-
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
-
Check you are up and running:
rustc --version
- what immediate mode is;
- how to create an egui app; and
- how to add a custom logo or icon to an egui app.
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 .
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" --publicgit clone \https://github.com/YOUR-GITHUB-PROFILE/cistercian-clock.git
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
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:

⌚️ 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:
1 [package]2 name = "cistercian_clock"3 version = "0.1.0"5 edition = "2021"6 rust-version = "1.81"789 [dependencies]10 chrono = "0.4.39"11 egui = "0.30"12 eframe = { version = "0.30", default-features = false, features = [13 "accesskit", # Make egui comptaible with screen readers. NOTE: adds a lot of dependencies.14 "default_fonts", # Embed the default egui fonts.15 "glow", # Use the glow rendering backend. Alternative: "wgpu".16 "persistence", # Enable restoring app state when restarting the app.17 "wayland", # To support Linux (and CI)18 ] }19 image = { version = "0.25.5", default-features = false, features = ["png"] }20 log = "0.4"2122 # You only need serde if you want app persistence:23 serde = { version = "1", features = ["derive"] }
I also added a couple of crates (Rust packages):
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
:
1 #![warn(clippy::all, rust_2018_idioms)]2 #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release34 // When compiling natively:5 #[cfg(not(target_arch = "wasm32"))]6 fn main() -> eframe::Result<()> {7 env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`).89 let native_options = eframe::NativeOptions {10 viewport: egui::ViewportBuilder::default()11 .with_inner_size([400.0, 300.0])12 .with_min_inner_size([300.0, 220.0]),13 ..Default::default()14 };15 eframe::run_native(16 "eframe template",17 native_options,18 Box::new(|cc| Ok(Box::new(cistercian_clock::CistercianClockApp::new(cc)))),19 )20 }
app.rs
#
src/app.rs
contains the main app logic. Change TemplateApp
to CistercianClockApp
here in lines 4
, 12
, 22
and 38
:
1 /// We derive Deserialize/Serialize so we can persist app state on shutdown.2 #[derive(serde::Deserialize, serde::Serialize)]3 #[serde(default)] // if we add new fields, give them default values when deserializing old state4 pub struct CistercianClockApp {5 // Example stuff:6 label: String,78 #[serde(skip)] // This how you opt-out of serialization of a field9 value: f32,10 }
There is one final change in src/lib.rs
, again replacing TemplateApp
with CitercianClockApp
:
1 #![warn(clippy::all, rust_2018_idioms)]23 mod app;4 pub use app::CistercianClockApp;
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
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:
1 #![warn(clippy::all, rust_2018_idioms)]2 #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release34 use egui::IconData;56 // When compiling natively:7 #[cfg(not(target_arch = "wasm32"))]8 fn main() -> eframe::Result<()> {9 env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`).1011 let icon_image =12 image::open("assets/icon-256.png").expect("Should be able to open icon PNG file");13 let width = icon_image.width();14 let height = icon_image.height();15 let icon_rgba8 = icon_image.into_rgba8().to_vec();16 let icon_data = IconData {17 rgba: icon_rgba8,18 width,19 height,20 };2122 let native_options = eframe::NativeOptions {23 viewport: egui::ViewportBuilder::default()24 .with_inner_size([400.0, 300.0])25 .with_min_inner_size([300.0, 220.0])26 .with_icon(icon_data),27 ..Default::default()28 };29 eframe::run_native(30 "eframe template",31 native_options,32 Box::new(|cc| Ok(Box::new(cistercian_clock::CistercianClockApp::new(cc)))),33 )34 }
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
:
21 let native_options = eframe::NativeOptions {22 viewport: egui::ViewportBuilder::default()23 .with_inner_size([750.0, 600.0])24 .with_min_inner_size([750.0, 600.0])25 .with_icon(icon_data),26 ..Default::default()27 };28 eframe::run_native(29 "Cistercian Clock",30 native_options,31 Box::new(|cc| Ok(Box::new(cistercian_clock::CistercianClockApp::new(cc)))),32 )
👀 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
The simplest command is:
cargo watch
That will just run your app after each source file change. I tend to run:
cargo watch -x check -x clippy -x run
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 pasting the chunks below, or just copying
the complete file from the finished egui Cistercian Clock project repo :
src/app.rs
— click to expand code.
1 use chrono::{Local, Timelike};2 use core::time::Duration;3 use egui::{4 epaint::Shadow,5 scroll_area::ScrollBarVisibility,6 style::{HandleShape, Selection, Widgets},7 vec2, Color32,8 FontFamily::Proportional,9 FontId, Painter, Pos2, Rounding, ScrollArea, Sense, Stroke,10 TextStyle::{self, Body, Button, Heading, Monospace, Name, Small},11 Ui, Vec2, Visuals,12 };1314 fn dark_mode_override() -> Visuals {15 Visuals {16 dark_mode: true,17 override_text_color: Some(Color32::from_gray(252)),18 widgets: Widgets::default(),19 selection: Selection::default(),20 hyperlink_color: Color32::from_rgb(90, 170, 255),21 faint_bg_color: Color32::from_additive_luminance(5), // visible, but barely so22 extreme_bg_color: Color32::from_gray(10), // e.g. TextEdit background23 code_bg_color: Color32::from_gray(64),24 warn_fg_color: Color32::from_rgb(255, 143, 0), // orange25 error_fg_color: Color32::from_rgb(255, 0, 0), // red2627 window_rounding: Rounding::same(6.0),28 window_shadow: Shadow {29 offset: vec2(10.0, 20.0),30 blur: 15.0,31 spread: 0.0,32 color: Color32::from_black_alpha(96),33 },34 window_fill: Color32::from_rgb(23, 18, 25),35 window_stroke: Stroke::new(1.0, Color32::from_gray(60)),36 window_highlight_topmost: true,3738 menu_rounding: Rounding::same(6.0),3940 panel_fill: Color32::from_rgb(23, 18, 25),4142 popup_shadow: Shadow {43 offset: vec2(10.0, 20.0),44 blur: 15.0,45 spread: 0.0,46 color: Color32::from_black_alpha(96),47 },48 resize_corner_size: 12.0,49 text_cursor: Default::default(),50 clip_rect_margin: 3.0, // should be at least half the size of the widest frame stroke + max WidgetVisuals::expansion51 button_frame: true,52 collapsing_header_frame: false,53 indent_has_left_vline: true,5455 striped: false,5657 slider_trailing_fill: false,5859 handle_shape: HandleShape::Rect { aspect_ratio: 1.0 },6061 interact_cursor: None,6263 image_loading_spinners: true,6465 numeric_color_space: NumericColorSpace::GammaByte,66 }67 }6869 pub fn light_mode_override() -> Visuals {70 Visuals {71 dark_mode: false,72 override_text_color: Some(Color32::from_rgb(4, 3, 15)),73 widgets: Widgets::light(),74 //selection: Selection::light(),75 selection: Selection::default(),76 hyperlink_color: Color32::from_rgb(0, 155, 255),77 faint_bg_color: Color32::from_additive_luminance(5), // visible, but barely so78 extreme_bg_color: Color32::from_gray(255), // e.g. TextEdit background79 code_bg_color: Color32::from_gray(230),80 warn_fg_color: Color32::from_rgb(255, 100, 0), // slightly orange red. it's difficult to find a warning color that pops on bright background.81 error_fg_color: Color32::from_rgb(255, 0, 0), // red8283 window_shadow: Shadow {84 offset: vec2(10.0, 20.0),85 blur: 15.0,86 spread: 0.0,87 color: Color32::from_black_alpha(25),88 },89 window_fill: Color32::from_gray(255),90 window_stroke: Stroke::new(1.0, Color32::from_gray(190)),9192 panel_fill: Color32::from_gray(255),9394 popup_shadow: Shadow {95 offset: vec2(6.0, 10.0),96 blur: 8.0,97 spread: 0.0,98 color: Color32::from_black_alpha(25),99 },100 text_cursor: TextCursorStyle {101 stroke: Stroke::new(2.0, Color32::from_rgb(0, 83, 125)),102 ..Default::default()103 },104 ..Visuals::dark()105 }106 }107108 /// We derive Deserialize/Serialize so we can persist app state on shutdown.109 #[derive(serde::Deserialize, serde::Serialize)]110 #[serde(default)] // if we add new fields, give them default values when deserializing old state111 pub struct CistercianClockApp {112 // Example stuff:113 label: String,114115 #[serde(skip)] // This how you opt-out of serialization of a field116 value: f32,117 }
This adds imports used later and theme colour overrides. Next, add this chunk, to the same file.
src/app.rs
— click to expand code.
107 impl CistercianClockApp {108 /// Called once before the first frame.109 pub fn new(cc: &eframe::CreationContext<'_>) -> Self {110 // This is also where you can customize the look and feel of egui using111 // `cc.egui_ctx.set_visuals` and `cc.egui_ctx.set_fonts`.112113 // Load previous app state (if any).114 // Note that you must enable the `persistence` feature for this to work.115 if let Some(storage) = cc.storage {116 return eframe::get_value(storage, eframe::APP_KEY).unwrap_or_default();117 }118119 Default::default()120 }121 }122123 struct Colours {124 colour_0: Color32,125 colour_1: Color32,126 colour_2: Color32,127 colour_3: Color32,128 colour_4: Color32,129 colour_6: Color32,130 }131132 fn paint_unit_number(133 painter: &mut Painter,134 centre: Pos2,135 scale: f32,136 colours: &Colours,137 number: u32,138 ) {139 let width = scale * (34.0 / 2.0 - 1.0);140 let stroke_width = if scale < 2.0 { 2.0 } else { scale * 1.0 };141 let Colours {142 colour_0,143 colour_1,144 colour_2,145 colour_3,146 colour_4,147 colour_6,148 } = colours;149 let stroke = Stroke::new(stroke_width, *colour_0);150 painter.line_segment(151 [centre - vec2(0.0, width), centre + vec2(0.0, width)],152 stroke,153 );154155 if number == 1 || number == 5 || number == 7 || number == 9 {156 let stroke = Stroke::new(stroke_width, *colour_1);157 painter.line_segment(158 [159 centre - vec2(0.0, width),160 centre + vec2(scale * 10.0, -width),161 ],162 stroke,163 );164 }165166 if number == 2 || number == 8 || number == 9 {167 let stroke = Stroke::new(stroke_width, *colour_2);168 painter.line_segment(169 [170 centre - vec2(0.0, width - scale * 10.0),171 centre + vec2(scale * 10.0, -width + (scale * 10.0)),172 ],173 stroke,174 );175 }176177 if number == 3 {178 let stroke = Stroke::new(stroke_width, *colour_3);179 painter.line_segment(180 [181 centre - vec2(0.0, width),182 centre + vec2(scale * 10.0, -width + scale * 10.0),183 ],184 stroke,185 );186 }187188 if number == 4 || number == 5 {189 let stroke = Stroke::new(stroke_width, *colour_4);190 painter.line_segment(191 [192 centre - vec2(0.0, width - scale * 10.0),193 centre + vec2(scale * 10.0, -width),194 ],195 stroke,196 );197 }198199 if number > 5 {200 let stroke = Stroke::new(stroke_width, *colour_6);201 painter.line_segment(202 [203 centre + vec2(scale * 10.0, -width),204 centre + vec2(scale * 10.0, -width + scale * 10.0),205 ],206 stroke,207 );208 }209 }210211 fn paint_tens_number(212 painter: &mut Painter,213 centre: Pos2,214 scale: f32,215 colours: &Colours,216 number: u32,217 ) {218 let width = scale * (34.0 / 2.0 - 1.0);219 let stroke_width = if scale < 2.0 { 2.0 } else { scale * 1.0 };220 let Colours {221 colour_1,222 colour_2,223 colour_3,224 colour_4,225 colour_6,226 ..227 } = colours;228229 if number == 1 || number == 5 || number == 7 || number == 9 {230 let stroke = Stroke::new(stroke_width, *colour_1);231 painter.line_segment(232 [233 centre - vec2(scale * 10.0, width),234 centre + vec2(0.0, -width),235 ],236 stroke,237 );238 }239 if number == 2 || number == 8 || number == 9 {240 let stroke = Stroke::new(stroke_width, *colour_2);241 painter.line_segment(242 [243 centre - vec2(scale * 10.0, width - scale * 10.0),244 centre + vec2(0.0, -width + scale * 10.0),245 ],246 stroke,247 );248 }249 if number == 3 {250 let stroke = Stroke::new(stroke_width, *colour_3);251 painter.line_segment(252 [253 centre - vec2(scale * 10.0, width - scale * 10.0),254 centre + vec2(0.0, -width),255 ],256 stroke,257 );258 }259 if number == 4 || number == 5 {260 let stroke = Stroke::new(stroke_width, *colour_4);261 painter.line_segment(262 [263 centre - vec2(scale * 10.0, width),264 centre + vec2(0.0, -width + scale * 10.0),265 ],266 stroke,267 );268 }269 if number > 5 {270 let stroke = Stroke::new(stroke_width, *colour_6);271 painter.line_segment(272 [273 centre + vec2(-scale * 10.0, -width),274 centre + vec2(-scale * 10.0, -width + scale * 10.0),275 ],276 stroke,277 );278 }279 }280281 fn paint_hundreds_number(282 painter: &mut Painter,283 centre: Pos2,284 scale: f32,285 colours: &Colours,286 number: u32,287 ) {288 let width = scale * (34.0 / 2.0 - 1.0);289 let stroke_width = if scale < 2.0 { 2.0 } else { scale * 1.0 };290 let Colours {291 colour_1,292 colour_2,293 colour_3,294 colour_4,295 colour_6,296 ..297 } = colours;298299 if number == 1 || number == 5 || number == 7 || number == 9 {300 let stroke = Stroke::new(stroke_width, *colour_1);301 painter.line_segment(302 [303 centre + vec2(0.0, width),304 centre + vec2(scale * 10.0, width),305 ],306 stroke,307 );308 }309 if number == 2 || number == 8 || number == 9 {310 let stroke = Stroke::new(stroke_width, *colour_2);311 painter.line_segment(312 [313 centre + vec2(0.0, width - scale * 10.0),314 centre + vec2(scale * 10.0, width - scale * 10.0),315 ],316 stroke,317 );318 }319 if number == 3 {320 let stroke = Stroke::new(stroke_width, *colour_3);321 painter.line_segment(322 [323 centre + vec2(0.0, width),324 centre + vec2(scale * 10.0, width - scale * 10.0),325 ],326 stroke,327 );328 }329 if number == 4 || number == 5 {330 let stroke = Stroke::new(stroke_width, *colour_4);331 painter.line_segment(332 [333 centre + vec2(0.0, width - scale * 10.0),334 centre + vec2(scale * 10.0, width),335 ],336 stroke,337 );338 }339 if number > 5 {340 let stroke = Stroke::new(stroke_width, *colour_6);341 painter.line_segment(342 [343 centre + vec2(scale * 10.0, width - scale * 10.0),344 centre + vec2(scale * 10.0, width),345 ],346 stroke,347 );348 }349 }350351 fn paint_thousands_number(352 painter: &mut Painter,353 centre: Pos2,354 scale: f32,355 colours: &Colours,356 number: u32,357 ) {358 let width = scale * (34.0 / 2.0 - 1.0);359 let stroke_width = if scale < 2.0 { 2.0 } else { scale * 1.0 };360 let Colours {361 colour_1,362 colour_2,363 colour_3,364 colour_4,365 colour_6,366 ..367 } = colours;368369 if number == 1 || number == 5 || number == 7 || number == 9 {370 let stroke = Stroke::new(stroke_width, *colour_1);371 painter.line_segment(372 [373 centre + vec2(-scale * 10.0, width),374 centre + vec2(0.0, width),375 ],376 stroke,377 );378 }379 if number == 2 || number == 8 || number == 9 {380 let stroke = Stroke::new(stroke_width, *colour_2);381 painter.line_segment(382 [383 centre + vec2(-scale * 10.0, width - scale * 10.0),384 centre + vec2(0.0, width - scale * 10.0),385 ],386 stroke,387 );388 }389 if number == 3 {390 let stroke = Stroke::new(stroke_width, *colour_3);391 painter.line_segment(392 [393 centre + vec2(-scale * 10.0, width - scale * 10.0),394 centre + vec2(0.0, width),395 ],396 stroke,397 );398 }399 if number == 4 || number == 5 {400 let stroke = Stroke::new(stroke_width, *colour_4);401 painter.line_segment(402 [403 centre + vec2(-scale * 10.0, width),404 centre + vec2(0.0, width - scale * 10.0),405 ],406 stroke,407 );408 }409 if number > 5 {410 let stroke = Stroke::new(stroke_width, *colour_6);411 painter.line_segment(412 [413 centre + vec2(-scale * 10.0, width - scale * 10.0),414 centre + vec2(-scale * 10.0, width),415 ],416 stroke,417 );418 }419 }420421 fn paint_number(422 ui: &mut Ui,423 colours: &Colours,424 number: u32,425 scale: Option<f32>,426 show_arabic_numeral: Option<bool>,427 ) {428 let scale = scale.unwrap_or(1.0);429 assert!((0..=9_999).contains(&number));430 match show_arabic_numeral {431 Some(true) => {432 match number {433 0..=999 => ui.label(number.to_string()),434 _ => ui.label(format!("{},{:003}", number / 1000, number % 1000)),435 };436 }437 None | Some(false) => {}438 }439440 let size = Vec2::splat(scale * 34.0);441 let (response, mut painter) = ui.allocate_painter(size, Sense::hover());442 let rect = response.rect;443 let c = rect.center();444445 let unit = number % 10;446 paint_unit_number(&mut painter, c, scale, colours, unit);447 if number > 9 {448 let tens = (number % 100) / 10;449 paint_tens_number(&mut painter, c, scale, colours, tens);450 }451 if number > 99 {452 let hundreds = (number % 1_000) / 100;453 paint_hundreds_number(&mut painter, c, scale, colours, hundreds);454 }455 if number > 999 {456 let thousands = (number % 10_000) / 1_000;457 paint_thousands_number(&mut painter, c, scale, colours, thousands);458 }459 }460461 const DARK_CISTERCIAN_NUMERAL_COLOURS: Colours = Colours {462 colour_0: Color32::from_gray(242),463 colour_1: Color32::from_rgb(58, 134, 255),464 colour_2: Color32::from_rgb(251, 86, 7),465 colour_3: Color32::from_rgb(162, 106, 241),466 colour_4: Color32::from_rgb(255, 0, 110),467 colour_6: Color32::from_rgb(255, 190, 11),468 };469470 const LIGHT_CISTERCIAN_NUMERAL_COLOURS: Colours = Colours {471 colour_0: Color32::from_rgb(4, 3, 15),472 colour_1: Color32::from_rgb(93, 93, 91),473 colour_2: Color32::from_rgb(0, 122, 94),474 colour_3: Color32::from_rgb(27, 42, 65),475 colour_4: Color32::from_rgb(150, 2, 0),476 colour_6: Color32::from_rgb(0, 122, 163),477 };478479 fn paint_number_row(ui: &mut Ui, colours: &Colours, start: u32, end: u32) {480 ui.horizontal(|ui| {481 for number in start..end {482 ui.horizontal_top(|ui| paint_number(ui, colours, number, None, Some(true)));483 }484 });485 }486487 impl eframe::App for CistercianClockApp {488 /// Called by the frame work to save state before shutdown.489 fn save(&mut self, storage: &mut dyn eframe::Storage) {490 eframe::set_value(storage, eframe::APP_KEY, self);491 }
That includes the functions we call to draw the numbers.
Finally, replace the update function (in the same file):
src/app.rs
— click to expand code.
508 impl eframe::App for CistercianClockApp {509 /// Called by the frame work to save state before shutdown.510 fn save(&mut self, storage: &mut dyn eframe::Storage) {511 eframe::set_value(storage, eframe::APP_KEY, self);512 }513514 /// Called each time the UI needs repainting, which may be many times per second.515 fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {516 // Put your widgets into a `SidePanel`, `TopPanel`, `CentralPanel`, `Window` or `Area`.517 // For inspiration and more examples, go to https://emilk.github.io/egui518519 egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {520 if ui.visuals().dark_mode {521 ctx.set_visuals(dark_mode_override());522 } else {523 ctx.set_visuals(light_mode_override());524 };525526 // The top panel is often a good place for a menu bar:527528 egui::menu::bar(ui, |ui| {529 // NOTE: no File->Quit on web pages!530 let is_web = cfg!(target_arch = "wasm32");531 if !is_web {532 ui.menu_button("File", |ui| {533 if ui.button("Quit").clicked() {534 ctx.send_viewport_cmd(egui::ViewportCommand::Close);535 }536 });537 ui.add_space(16.0);538 }539540 egui::widgets::global_theme_preference_switch(ui);541 });542 });543544 let mut style = (*ctx.style()).clone();545 style.text_styles = [546 (Heading, FontId::new(30.0, Proportional)),547 (Name("clock".into()), FontId::new(64.0, Proportional)),548 (Body, FontId::new(18.0, Proportional)),549 (Monospace, FontId::new(14.0, Proportional)),550 (Button, FontId::new(14.0, Proportional)),551 (Small, FontId::new(10.0, Proportional)),552 ]553 .into();554 ctx.set_style(style);555556 egui::CentralPanel::default().show(ctx, |ui| {557 let colours = if ui.visuals().dark_mode {558 DARK_CISTERCIAN_NUMERAL_COLOURS559 } else {560 LIGHT_CISTERCIAN_NUMERAL_COLOURS561 };562 ui.ctx().request_repaint_after(Duration::new(1, 0));563 // The central panel the region left after adding TopPanel's and SidePanel's564 ui.heading("Cistercian Time");565 ui.add_space(30.0);566567 let now = Local::now();568 let hours_minutes: u32 = now.hour() * 100 + now.minute();569 let seconds: u32 = now.second();570 let time = now.format("%H:%M %S").to_string();571 ui.horizontal(|ui| {572 paint_number(ui, &colours, hours_minutes, Some(4.0), None);573 paint_number(ui, &colours, seconds, Some(4.0), None);574 });575 ui.add_space(20.0);576 ui.horizontal(|ui| {577 ui.style_mut().override_text_style = Some(TextStyle::Name("clock".into()));578 ui.label(time)579 });580 ui.add_space(20.0);581582 ui.separator();583 ScrollArea::vertical()584 .auto_shrink(false)585 .scroll_bar_visibility(ScrollBarVisibility::default())586 .show(ui, |ui| {587 ui.heading("Cistercian Numbers");588 ui.add_space(30.0);589 paint_number_row(ui, &colours, 0, 10);590 ui.add_space(30.0);591 for tens in 1..10 {592 paint_number_row(ui, &colours, 10 * tens, (tens + 1) * 10);593 ui.add_space(15.0);594 }595596 ui.add_space(30.0);597 ui.horizontal(|ui| {598 for number in 1..5 {599 ui.horizontal_top(|ui| {600 paint_number(ui, &colours, number * 100, None, Some(true));601 });602 }603 });604605 ui.add_space(30.0);606 ui.horizontal(|ui| {607 for number in 1..5 {608 ui.horizontal_top(|ui| {609 paint_number(ui, &colours, number * 1_000, None, Some(true));610 });611 }612 });613614 ui.add_space(30.0);615 ui.separator();616617 ui.horizontal(|ui| {618 ui.with_layout(egui::Layout::bottom_up(egui::Align::LEFT), |ui| {619 powered_by_egui_and_eframe(ui);620 egui::warn_if_debug_build(ui);621 });622 });623 });624 });625 }626 }
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: 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.
519 egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {520 if ui.visuals().dark_mode {521 ctx.set_visuals(dark_mode_override());522 } else {523 ctx.set_visuals(light_mode_override());524 };525526 // The top panel is often a good place for a menu bar:527528 egui::menu::bar(ui, |ui| {529 // NOTE: no File->Quit on web pages!530 let is_web = cfg!(target_arch = "wasm32");531 if !is_web {532 ui.menu_button("File", |ui| {533 if ui.button("Quit").clicked() {534 ctx.send_viewport_cmd(egui::ViewportCommand::Close);535 }536 });537 ui.add_space(16.0);538 }539540 egui::widgets::global_theme_preference_switch(ui);541 });542 });
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 562
, 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 562
back in so the clock updates normally again.
556 egui::CentralPanel::default().show(ctx, |ui| {557 let colours = if ui.visuals().dark_mode {558 DARK_CISTERCIAN_NUMERAL_COLOURS559 } else {560 LIGHT_CISTERCIAN_NUMERAL_COLOURS561 };562 ui.ctx().request_repaint_after(Duration::new(1, 0));563 // The central panel the region left after adding TopPanel's and SidePanel's564 ui.heading("Cistercian Time");565 ui.add_space(30.0);566 });
Trying egui: Adding a Vertical Scroll #
We can also add a scrollable area, in egui, with only a few lines of code:
583 ScrollArea::vertical()584 .auto_shrink(false)585 .scroll_bar_visibility(ScrollBarVisibility::default())586 .show(ui, |ui| {587 ui.heading("Cistercian Numbers");588 ui.add_space(30.0);589 paint_number_row(ui, &colours, 0, 10);590 ui.add_space(30.0);591 for tens in 1..10 {592 paint_number_row(ui, &colours, 10 * tens, (tens + 1) * 10);593 ui.add_space(15.0);594 }595 // ...TRUNCATED596 });
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
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 .
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.30
, which uses Rust 1.81
. 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:
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.
Just tried out egui. It’s a 🦀 Rust, immediate mode GUI tool, inspired by the C++ Dear ImGui library.
— Rodney (@askRodney) January 3, 2024
— it’s great for game back-ends;
— quick to code in because of immediate mode; and
— there’s a demo app with a lot of code examples.
#askRodney #learnRust pic.twitter.com/nwHYxU5fUR
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.