Opens an external site in a new window
Mental Health Awareness Month
“Community”
RODNEY LAB
  • Home
  • Plus +
  • Newsletter
  • Links
  • Profile
RODNEY LAB
  • Home
  • Plus +
  • Newsletter
  • Links

Trying egui: building a Cistercian Clock with Rust GUI ⌚️ # Trying egui: building a Cistercian Clock with Rust GUI ⌚️ #

blurry low resolution placeholder image Trying egui
  1. Home Rodney Lab Home
  2. Blog Posts Rodney Lab Blog Posts
  3. Rust Rust Blog Posts
<PREVIOUS POST
NEXT POST >
LATEST POST >>

Trying egui: building a Cistercian Clock with Rust GUI ⌚️ #

Updated 2 months ago
17 minute read
Gunning Fog Index: 5.3
Content by Rodney
blurry low resolution placeholder image Author Image: Rodney from Rodney Lab
SHARE:

🖥️ 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 📼

Trying egui: What we Build with egui in 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
  2. 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
    2. 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

      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:

      blurry low resolution placeholder image 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.
      Trying egui: Starter Template Screen Capture

      ⌚️ 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"
      4 authors = ["Your Name <[email protected]>"]
      5 edition = "2021"
      6 rust-version = "1.81"
      7
      8
      9 [dependencies]
      10 chrono = "0.4.40"
      11 egui = "0.31.1"
      12 eframe = { version = "0.31.1", 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.6", default-features = false, features = ["png"] }
      20 log = "0.4.27"
      21
      22 # You only need serde if you want app persistence:
      23 serde = { version = "1.0.219", features = ["derive"] }

      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:

          
      1 #![warn(clippy::all, rust_2018_idioms)]
      2 #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release
      3
      4 // 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`).
      8
      9 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 state
      4 pub struct CistercianClockApp {
      5 // Example stuff:
      6 label: String,
      7
      8 #[serde(skip)] // This how you opt-out of serialization of a field
      9 value: f32,
      10 }

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

      src/rust.lib
      rust
          
      1 #![warn(clippy::all, rust_2018_idioms)]
      2
      3 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:

      src/main.rs
      rust
          
      1 #![warn(clippy::all, rust_2018_idioms)]
      2 #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release
      3
      4 use egui::IconData;
      5
      6 // 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`).
      10
      11 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 };
      21
      22 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:

      src/main.rs
      rust
          
      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.
      src/app.rs
      rust
          
      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 };
      13
      14 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 so
      22 extreme_bg_color: Color32::from_gray(10), // e.g. TextEdit background
      23 code_bg_color: Color32::from_gray(64),
      24 warn_fg_color: Color32::from_rgb(255, 143, 0), // orange
      25 error_fg_color: Color32::from_rgb(255, 0, 0), // red
      26
      27 window_corner_radius: CornerRadius::same(6),
      28 window_shadow: Shadow {
      29 offset: [10, 20],
      30 blur: 15,
      31 spread: 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,
      37
      38 menu_corner_radius: CornerRadius::same(6),
      39
      40 panel_fill: Color32::from_rgb(23, 18, 25),
      41
      42 popup_shadow: Shadow {
      43 offset: [6, 10],
      44 blur: 8,
      45 spread: 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::expansion
      51 button_frame: true,
      52 collapsing_header_frame: false,
      53 indent_has_left_vline: true,
      54
      55 striped: false,
      56
      57 slider_trailing_fill: false,
      58
      59 handle_shape: HandleShape::Rect { aspect_ratio: 1.0 },
      60
      61 interact_cursor: None,
      62
      63 image_loading_spinners: true,
      64
      65 numeric_color_space: NumericColorSpace::GammaByte,
      66 }
      67 }
      68
      69 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 so
      78 extreme_bg_color: Color32::from_gray(255), // e.g. TextEdit background
      79 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), // red
      82
      83 window_shadow: Shadow {
      84 offset: [10, 20],
      85 blur: 15,
      86 spread: 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)),
      91
      92 panel_fill: Color32::from_gray(255),
      93
      94 popup_shadow: Shadow {
      95 offset: [6, 10],
      96 blur: 8,
      97 spread: 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 }
      107
      108 /// 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 state
      111 pub struct CistercianClockApp {
      112 // Example stuff:
      113 label: String,
      114
      115 #[serde(skip)] // This how you opt-out of serialization of a field
      116 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.
      src/app.rs
      rust
          
      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 using
      111 // `cc.egui_ctx.set_visuals` and `cc.egui_ctx.set_fonts`.
      112
      113 // 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 }
      118
      119 Default::default()
      120 }
      121 }
      122
      123 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 }
      131
      132 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 );
      154
      155 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 }
      165
      166 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 }
      176
      177 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 }
      187
      188 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 }
      198
      199 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 }
      210
      211 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;
      228
      229 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 }
      280
      281 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;
      298
      299 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 }
      350
      351 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;
      368
      369 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 }
      420
      421 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 }
      439
      440 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();
      444
      445 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 }
      460
      461 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 };
      469
      470 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 };
      478
      479 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 }
      486
      487 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.
      src/app.rs
      rust
          
      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 }
      513
      514 /// 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/egui
      518
      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 };
      525
      526 // The top panel is often a good place for a menu bar:
      527
      528 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 }
      539
      540 egui::widgets::global_theme_preference_switch(ui);
      541 });
      542 });
      543
      544 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);
      555
      556 egui::CentralPanel::default().show(ctx, |ui| {
      557 let colours = if ui.visuals().dark_mode {
      558 DARK_CISTERCIAN_NUMERAL_COLOURS
      559 } else {
      560 LIGHT_CISTERCIAN_NUMERAL_COLOURS
      561 };
      562 ui.ctx().request_repaint_after(Duration::new(1, 0));
      563 // The central panel the region left after adding TopPanel's and SidePanel's
      564 ui.heading("Cistercian Time");
      565 ui.add_space(30.0);
      566
      567 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);
      581
      582 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 }
      595
      596 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 });
      604
      605 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 });
      613
      614 ui.add_space(30.0);
      615 ui.separator();
      616
      617 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.

      blurry low resolution placeholder image 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: Cistercian Clock App

      🧐 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.

      src/app.rs
      rust
          
      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 };
      525
      526 // The top panel is often a good place for a menu bar:
      527
      528 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 }
      539
      540 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.

      src/app.rs
      rust
          
      556 egui::CentralPanel::default().show(ctx, |ui| {
      557 let colours = if ui.visuals().dark_mode {
      558 DARK_CISTERCIAN_NUMERAL_COLOURS
      559 } else {
      560 LIGHT_CISTERCIAN_NUMERAL_COLOURS
      561 };
      562 ui.ctx().request_repaint_after(Duration::new(1, 0));
      563 // The central panel the region left after adding TopPanel's and SidePanel's
      564 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:

      src/app.rs
      rust
          
      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 // ...TRUNCATED
      596 });

      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:

      • 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.

      blurry low resolution placeholder image ask Rodney X (formerly Twitter) avatar

      Rodney

      @askRodney

      Just tried out egui. It’s a 🦀 Rust, immediate mode GUI tool, inspired by the C++ Dear ImGui library.

      — 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

      — Rodney (@askRodney) January 3, 2024

      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.

Thanks for reading this post. I hope you found it valuable. Please get in touch with your feedback and suggestions for posts you would like to see. Read more about me …

blurry low resolution placeholder image Rodney from Rodney Lab
TAGS:
RUSTGAMING

Related Posts

blurry low resolution placeholder image Godot Rust CI: Handy GDScript & Rust GitHub Actions 🎬

Godot Rust CI: Handy GDScript & Rust GitHub Actions 🎬

rust
gaming
<PREVIOUS POST
NEXT POST >
LATEST POST >>

Leave a comment …

Your information will be handled in line with our Privacy Policy .

Ask for more

1 Nov 2022 — Astro Server-Side Rendering: Edge Search Site
3 Oct 2022 — Svelte eCommerce Site: SvelteKit Snipcart Storefront
1 Sept 2022 — Get Started with SvelteKit Headless WordPress

Copyright © 2020 – 2025 Rodney Johnson. All Rights Reserved. Please read important copyright and intellectual property information.

  • Home
  • Profile
  • Plus +
  • Newsletter
  • Contact
  • Links
  • Terms of Use
  • Privacy Policy
We use cookies  to enhance visitors’ experience. Please click the “Options” button to make your choice.  Learn more here.