Skip to content

Search is only available in production builds. Try building and previewing the site to test it out locally.

9.1 GUI Programming for Edge Systems

This content is a draft and will not be included in production builds.

TODO: This is quite raw and work in progress. It needs much more descriptive text but the examples are good.

Developing graphical user interfaces (GUIs) in Rust presents a rapidly evolving landscape. Unlike more established ecosystems where a few dominant frameworks often dictate development practices, the Rust GUI space is characterised by a dynamic and swift pace of innovation. This constant evolution means that frameworks can become outdated relatively quickly, necessitating continuous engagement and adaptation from developers. This fluidity contrasts sharply with the more settled choices found in web development, such as React, where a clear “best” option often emerges. For developers, this fragmentation offers both the challenge of selecting the most appropriate tool and the opportunity for specialisation within a diverse set of options.

The Evolving Landscape of Rust GUI Frameworks

Section titled “The Evolving Landscape of Rust GUI Frameworks”

The Rust GUI ecosystem encompasses a variety of approaches, each with distinct philosophies and target use cases. Understanding these prominent cross-platform options is crucial for making informed development decisions.

Tauri is a framework engineered to produce small and fast binaries for a wide array of desktop operating systems, including Windows, Linux, and macOS, with expanding support for mobile platforms like iOS and Android. Its architectural strength lies in its hybrid approach: it leverages familiar web technologies (HTML, CSS, JavaScript) for the user interface, while the application’s core logic is powered by a Rust backend. An advantage of Tauri applications is their minimal distribution size, often as compact as 600KB. This is achieved by utilising the operating system’s native web renderer, thereby avoiding the overhead of embedding a large runtime environment. Tauri facilitates inter-process communication (IPC) through two primary mechanisms: a Command system, which enables type-safe, synchronous calls from the frontend to the Rust backend, and an Event system, designed for one-way, asynchronous message passing. These mechanisms ensure seamless interaction between the UI and the application logic. Tauri’s architecture is inherently modular, offering extensive flexibility to integrate with various frontend frameworks.

Dioxus is presented as a comprehensive “fullstack cross platform app framework for Rust”. It adopts a user interface architecture reminiscent of React, allowing developers to build applications for desktop (often integrating with Tauri or experimentally using WGPU/Skia), web, and mobile (iOS, Android) from a single, unified codebase. Dioxus places a strong emphasis on ergonomic state management. A key distinction from Tauri is Dioxus’s native Rust UI rendering, which allows direct system access without the need for IPC, as opposed to Tauri’s reliance on JavaScript or WebAssembly for its UI layer.

Slint is an open-source, declarative GUI toolkit that supports the creation of native user interfaces for applications developed in Rust, C++, JavaScript, or Python. It targets a diverse range of platforms, from embedded systems to desktop and mobile environments. Slint’s UI designs are defined using a custom .slint markup language, which is then compiled directly into native machine code, contributing to its performance characteristics. The framework prioritises lightweight operation, with a runtime footprint of less than 300KB RAM, and features a reactive property system. It also provides robust tooling, including a Live Preview feature and integrated editor support, to enhance the developer experience.

GTK-rs provides safe Rust bindings for the foundational libraries of the GNOME stack, with a particular focus on GTK 4. As a mature and well-established option, it offers comprehensive widget coverage and strong integration with the native aesthetic of Gnome desktop environments. However, GTK-rs is often associated with a steep learning curve, potentially requiring a significant amount of boilerplate code for application development. Its cross-platform compatibility on Windows and Apple systems may not be as streamlined or robust as frameworks that leverage web technologies. GTK-rs applications are inherently event-driven, with their functionality managed by a main event loop that efficiently handles various types of events, from user input to system notifications.

Beyond these primary contenders, the Rust GUI ecosystem includes other notable frameworks such as RustUI, Azul, Conrod, Cacao (macOS-specific), Crux, Cushy, CXX-Qt (which provides Rust bindings to the Qt C++ GUI library), Floem, Xilem, and Ratatui (a Text User Interface, distinct from a Graphical User Interface). Many of these options may be platform-specific, present complex setup challenges, or are currently less mature or actively maintained compared to the more prominent frameworks. I may revisit them in future versions of this module.

To provide a concise overview of these diverse options, Table 1 offers a high-level comparison of the prominent Rust GUI frameworks. This table serves as an immediate reference point, summarising key characteristics and trade-offs to aid in understanding the broader landscape.

Table 1: High-Level Comparison of Rust GUI Frameworks

Framework NamePrimary ParadigmCross-Platform Support (Desktop)Key Differentiator/PhilosophyMaturity/StabilityLearning Curve (Rust-specific)
eguiImmediate ModeWindows, Linux, macOSSimplicity, rapid iteration, debugging, visualisationEvolving/StableModerate
TauriWebviewWindows, Linux, macOSLightweight, secure, web-tech integration, Rust backendStableLow (for frontend), Moderate (for Rust IPC)
DioxusDeclarative (React-like)Windows, Linux, macOSFullstack, hot-reloading, native Rust UI, ergonomic stateEvolvingModerate
SlintDeclarative (DSL)Windows, Linux, macOSDesigner-friendly, native compilation, low resource usageEvolving/StableModerate
GTK-rsRetained ModeLinux (Primary), Windows, macOSNative Gnome look, mature bindings, comprehensive widgetsMatureSteep

Immediate Mode vs. Retained Mode GUIs: A Fundamental Distinction

Section titled “Immediate Mode vs. Retained Mode GUIs: A Fundamental Distinction”

A foundational distinction in GUI programming paradigms lies between Immediate Mode GUIs (IMGUI) and Retained Mode GUIs (RMGUI). Understanding this difference is critical for appreciating the design choices and implications of various frameworks.

Immediate Mode GUIs (IMGUI) operate on the principle that the application code responsible for defining and drawing widgets is executed entirely during every single frame redraw. This means that at a typical refresh rate of 60 frames per second, the UI is conceptually rebuilt from scratch each time. In this paradigm, widgets do not persist as distinct, long-lived objects in memory. Instead, their appearance and interaction state are determined and rendered dynamically during each frame’s redraw cycle. This approach significantly simplifies the underlying UI logic, as developers are freed from the complexities of managing a hierarchical widget tree or explicitly registering callbacks for state changes; the UI is simply declared based on the application’s current data state.

A significant advantage of the IMGUI paradigm is its simpler mental model for developers. This is particularly beneficial for applications such as game engines or highly interactive visualisations, where the GUI library requires less intrusive control over direct GPU communication. egui stands as a prominent example of an IMGUI. However, a potential drawback is the risk of performance degradation if not meticulously managed. Since the entire UI is conceptually regenerated during each frame, excessive computation or “heavy lifting” performed on the main thread can lead to an unresponsive user interface and noticeable drops in frame rates. While advanced IMGUI libraries incorporate internal caching mechanisms to mitigate this, the fundamental principle remains focused on per-frame re-drawing. Furthermore, egui’s interaction with asynchronous operations can sometimes introduce performance penalties, particularly in web environments.

Retained Mode GUIs (RMGUI), conversely, operate on the principle that widgets are instantiated once and subsequently “retain” their state and existence throughout their lifecycle. The code responsible for building and updating widgets is executed only when specific changes occur or when a particular part of the UI explicitly requires modification. Developers interact with these persistent widget objects by setting their properties and attaching event handlers to them. This approach can yield superior performance for static or less frequently updated user interfaces, as only the modified elements need to be redrawn. It is also a more traditional and familiar paradigm for developers transitioning from established frameworks such as Java Swing, GTK, or Apple’s Cocoa. The primary challenge with RMGUIs, especially in a language like Rust, lies in managing mutable state and the intricate relationships between numerous persistent objects, which can become complex. RMGUIs typically necessitate explicit callback mechanisms and often involve more boilerplate code for UI definition and interaction.

It is important to recognise that the choice between these paradigms often involves a nuanced trade-off, rather than a simple selection of the “fastest” or “easiest” option. The “simplicity” often attributed to IMGUI primarily pertains to the developer’s mental model of UI updates: developers declare the desired state, and the system handles the rest by rebuilding. However, this conceptual simplicity does not automatically translate to effortless performance, as inefficient updates can still penalise the application. Conversely, the “performance” of RMGUI refers to its rendering efficiency — only redrawing what is absolutely necessary. Yet, this comes at the cost of increased development complexity in managing mutable, persistent UI state, a task that can be particularly challenging within Rust’s strict ownership model. In the context of Rust, IMGUI frameworks like egui offer a compelling solution by abstracting away much of the mutable state management complexity that would otherwise be cumbersome in a retained-mode Rust application. This allows developers to focus on the declarative description of the UI, while the library handles rendering optimisations, making the development process more streamlined and less prone to common Rust-specific challenges related to mutability and borrowing.

Why egui? Rationale for its Selection and Focus

Section titled “Why egui? Rationale for its Selection and Focus”

egui (pronounced “e-gooey”) is an immediate mode GUI library specifically designed for Rust. Its core design philosophy centres on providing a simple, fast, and portable toolkit that prioritises ease of use and rapid development. This emphasis on developer experience and conceptual clarity makes it an excellent choice for a university module.

A key strength of egui is its cross-platform portability. It runs natively on major desktop operating systems (Windows, Linux, macOS) and can also be compiled to WebAssembly (WASM) for direct execution within a web browser. This broad compatibility is primarily facilitated by eframe, which serves as the officially recommended framework for building egui applications targeting both native desktop environments and the web. Beyond desktop and web, egui also supports integration with various game engines, such as Bevy.

egui’s immediate mode nature renders it useful for debugging purposes and when developing high fidelity products. This characteristic directly aligns with the module’s specific requirement for graphing and advanced graphical visualisation capabilities, as it allows for dynamic and responsive drawing. Figure 1 links to a short demonstration of Rust egui in action, showing quite a versatile set of GUI components.

Figure 1. A Demo of Rust egui in action in a web page on https://www.egui.rs/

egui also streamlines UI logic by implicitly rebuilding the user interface during each frame. This approach reduces the need for complex callback management, explicit widget state storage, and synchronisation mechanisms often found in retained-mode GUIs. This inherent simplicity can be particularly advantageous for students learning GUI concepts, as it abstracts away some of the more challenging aspects of traditional state management and event handling.

Despite the rapid pace of change within the Rust GUI landscape, egui maintains a strong position as a well-regarded and actively developed project.

To begin developing GUI applications with egui, understanding its fundamental structure and the recommended setup process is important. The eframe crate serves as the primary and officially recommended abstraction layer, simplifying the complexities of cross-platform windowing and rendering for egui applications.

Setting Up a First egui Project with eframe

Section titled “Setting Up a First egui Project with eframe”

The most straightforward and officially recommended method for initiating an egui application is by utilising eframe. This framework provides support for both native desktop and web deployments, abstracting away much of the underlying platform-specific complexities.

The process begins with standard Rust project creation:

  1. Create a new Rust project: Open your terminal or command prompt and execute the command: cargo new my_egui. This command generates a new directory named my_egui containing a basic Rust project structure, including a Cargo.toml file and a src/main.rs file as is usual.
Terminal window
PS C:\temp> cargo new my_egui
Creating binary (application) `my_egui` package
note: see more `Cargo.toml` keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
  1. Add eframe as a dependency: The easiest way to add egui is to use cargo to update your cargo.toml file as follows:
Terminal window
PS C:\temp> cargo add eframe

Which should result in a change similar to the following:

[package]
name = "my_egui"
version = "0.1.0"
edition = "2024"
[dependencies]
eframe = "0.32.3"

The following is a minimal, but sophisticated, “Hello World” application structure (src/main.rs): This establishes a basic window and an interactive element, demonstrating the core egui application loop. The #[cfg_attr(not(debug_assertions), windows_subsystem = "windows")] attribute is a common practice to suppress the console window on Windows when building release versions, preventing a command prompt from appearing alongside the GUI.

#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
// Hide console window on Windows in release
use eframe::{egui, App, Frame};
use egui::{CentralPanel, Context, Label, Ui};
/// Defines the application's mutable state.
/// This struct holds all data that needs to persist and change throughout the application's lifetime.
struct MyApp {
label_text: String,
button_clicks: u32,
}
/// Implements the Default trait to provide an initial state for MyApp.
/// This is a convenient way to set up the application's starting values.
impl Default for MyApp {
fn default() -> Self {
Self {
label_text: "Hello, egui!".to_owned(),
button_clicks: 0,
}
}
}
/// Implements the eframe::App trait, which defines the core logic of the egui application.
/// The update method is the heart of an immediate mode GUI.
impl App for MyApp {
/// The update method is called once per frame by eframe to redraw the UI and handle input.
/// ctx: Provides access to the egui context, used for adding widgets and managing UI state.
/// _frame: Provides access to the native window frame, for operations like closing the window.
fn update(&mut self, ctx: &Context, _frame: &mut Frame) {
// CentralPanel occupies the main, central area of the window.
// It's a convenient way to place UI elements that fill the available space.
CentralPanel::default().show(ctx, |ui| {
ui.heading("My First egui Application"); // Display a prominent heading
ui.label(&self.label_text); // Display the current text from our application state
// Create a button. The .clicked() method returns true if the button was pressed.
// In immediate mode, this check happens every frame.
if ui.button("Click Me!").clicked() {
self.button_clicks += 1; // Increment the counter
// Update the label text based on the new counter value.
// This change will be reflected in the next frame's redraw.
self.label_text = format!("Button clicked {} times!", self.button_clicks);
}
});
}
}
/// The main function is the entry point of the Rust program.
/// It sets up and runs the eframe application.
fn main() -> eframe::Result<()> {
// Configure native window options, such as initial size and title.
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default().with_inner_size([320.0, 240.0]),
// Set initial window size
..Default::default() // Use default values for other options
};
// Run the native eframe application.
// "My egui" is the window title.
// options configures the native window.
// The closure Box::new(|_cc| Ok(Box::new(MyApp::default()))) creates and returns
// an instance of our MyApp struct, which defines the application's UI and logic.
eframe::run_native(
"My egui", // Window title
options,
Box::new(|_cc| Ok(Box::new(MyApp::default()))),
)
}

Figure 2. An egui first example.

In this example, the MyApp struct serves as the container for the application’s mutable state, holding values like label_text and button_clicks. The impl App for MyApp block defines the update method, which is invoked by eframe on every frame to handle UI rendering and user input. Within update, CentralPanel::default().show(ctx, |ui| {... }) establishes a central area for UI elements. These elements are then added using the ui object. ui.heading and ui.label are used to display static and dynamic text, respectively, while ui.button("Click Me!").clicked() demonstrates a fundamental interaction: when the button is pressed, the button_clicks counter is incremented, and the label_text is updated accordingly. This change is immediately reflected in the subsequent frame’s redraw.

Figure 3. The egui documentation at https://docs.rs/egui/latest/egui/

The rust documentation describes all of the functionality available with egui. This documentation will be available during the examination. For example, to learn more about the ui.button use the menu on the left → Crate Items → Structs → Ui → button (https://docs.rs/egui/latest/egui/struct.Ui.html#method.button)

The use of eframe as an abstraction layer is an important aspect of achieving cross-platform compatibility. Instead of egui directly interacting with low-level operating system windowing APIs (such as Win32 on Windows, X11 on Linux, or Cocoa on macOS) or graphics APIs (like DirectX, Vulkan, or Metal), eframe handles this intricate complexity. It achieves this by leveraging platform-specific libraries, such as winit for window management and glow (for OpenGL rendering) or wgpu (for WebGPU rendering) for graphics. This design means that egui itself remains largely agnostic to the specific platform details, as eframe provides a unified, higher-level interface. This separation of concerns is a fundamental principle in modern software engineering, illustrating how complex cross-platform compatibility is achieved. The UI framework (egui) focuses solely on rendering widgets and processing abstract input, while a dedicated backend framework (eframe) translates these abstract operations into platform-specific system calls. This significantly simplifies the developer’s task, allowing them to write egui-specific code without requiring deep knowledge of the underlying operating system or graphics APIs.

In Rust, a closure is an anonymous function you can create in-place. As you have seen C++ lambdas previously, you are already 90% of the way there. They serve the exact same purpose: creating a short, on-the-fly function that can “capture” variables from its surrounding environment. The syntax is very similar.

  • C++ Lambda: [capture_list](parameters) { body }
  • Rust Closure: |parameters| { body }

The most significant difference isn’t the syntax, but the capturing mechanism. In C++, you are explicit about *how *you capture variables:

  • [&] captures by reference.
  • [=] captures by value (a copy).
  • [this] captures the this pointer by value.
  • [&a, b] captures a by reference and b by value.

In Rust, capturing is implicit and inferred by the compiler. Rust analyzes what you do with the captured variables inside the closure and automatically chooses the most efficient and safe way to borrow or move them. Based on how it captures, the compiler assigns one of three traits to the closure:

  1. Fn (The “Immutable Borrow”): The closure only needs to read captured variables.
  2. FnMut (The “Mutable Borrow”): The closure needs to mutate (change) captured variables. This is the most common trait in egui.
  3. FnOnce (The “Ownership Move”): The closure takes ownership of a captured variable (i.e., consumes it). As the name implies, it can only be called once.

So, you don’t tell the Rust compiler how to capture (like [&] or [=]). You just use the variables, and the compiler infers whether it needs &T, &mut T, or T, assigning the Fn, FnMut, or FnOnce trait accordingly.

Rust closures are commonly used in egui for capturing variables and simplifying the overall UI layout process. They are also required later when we look at spawning threads in Chapter 11.

In egui, you don’t typically use closures for a simple button click (that’s usually just an if statement). Instead, you use closures to define layout and pass in the app’s state. The most common pattern is passing a closure to a layout function like egui::CentralPanel::show or ui.horizontal. This closure captures your application’s state (self) so it can read and modify it.

Imagine you have a simple egui app with some state:

// This is the main state for our application
struct MyApp {
name: String,
counter: i32,
}
// This is just a snippet from the main 'update' method
// where the UI is drawn every frame.
fn update(&mut self, ctx: &egui::Context) {
// egui::CentralPanel::show takes two arguments:
// 1. The context (ctx)
// 2. A closure!
egui::CentralPanel::default().show(ctx, |ui| {
// This is the body of our closure.
// 'ui' is the parameter (its type is &mut egui::Ui)
// --- 1. Capturing `&mut self` implicitly ---
// We need to modify self.counter, so the closure
// captures `self` as a mutable borrow (&mut self).
// This makes the closure `FnMut`.
ui.heading("A Simple egui App");
ui.label(format!("Counter: {}", self.counter));
if ui.button("Increment").clicked() {
self.counter += 1; // Needs &mut self
}
// --- 2. A nested closure ---
// ui.horizontal also takes a closure!
ui.horizontal(|ui_inner| {
// This closure ALSO captures `&mut self`
ui_inner.label("Your name: ");
ui_inner.text_edit_singleline(&mut self.name); // Needs &mut self
});
});
}

One point of confusion is that ui_inner (or ui in the outer closure) is not a special keyword. It is a parameter name that I chose for the closure. It could have been called derek. So:

  1. We call ui.horizontal(...).
  2. We pass it a closure: |ui_inner| { ... }.
  3. The horizontal function executes.
  4. Inside horizontal, it creates a new &mut egui::Ui (let’s call it horizontal_ui). This new Ui object is configured to make widgets line up side-by-side.
  5. The horizontal function then calls our closure and passes its horizontal_ui as the first and only argument.
  6. Our closure receives that argument and names it ui_inner.
  7. We can then call methods on ui_inner (like ui_inner.label(...)) to add widgets to that special horizontal layout.

So, ui_inner is just the name we gave to the &mut egui::Ui that ui.horizontal gave back to us.

Before diving into custom graphics with the Painter API, it’s essential to master the standard widgets that form the backbone of most user interfaces. egui provides a rich set of built-in components like labels, buttons, text inputs, sliders, and checkboxes. This example demonstrates how to wire up these widgets to a shared application state to create a simple, interactive counter.

Key Concepts Introduced in this Example:

  • Widget Interaction: Using buttons to modify application state.
  • Displaying State: Using labels to display data from the application state.
  • Two-Way Data Binding: Using a slider that both reads from and writes to the application state.
  • Layouts: Using ui.horizontal to arrange widgets.

Figure 5 The egui Counter App

This is Cargo.toml:

[package]
name = "egui"
version = "0.1.0"
edition = "2024"
[dependencies]
eframe = "0.32.3"

And main.rs:

#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
// This attribute hides the console window on Windows when running in *release mode*.
// It keeps the app looking like a normal desktop GUI app rather than a console program.
use eframe::egui;
// Import the main GUI library (egui) through the eframe framework.
// eframe provides an easy way to build native desktop apps using egui for rendering.
// Define the application structure
struct CounterApp {
count: i32, // This variable stores the current counter value.
}
// Provide a default value for CounterApp.
impl Default for CounterApp {
fn default() -> Self {
Self { count: 0 } // Start the counter at 0 when the app launches.
}
}
// Implement the eframe::App trait, which defines how the app behaves each frame.
impl eframe::App for CounterApp {
// The update() function is called on every frame to redraw the user interface.
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// The CentralPanel is a main area that automatically expands to fill the window.
egui::CentralPanel::default().show(ctx, |ui| {
// Add a title at the top
ui.heading("EEN1097 Simple Counter App");
ui.separator(); // Draws a horizontal line for visual separation.
// Set up a layout so that all widgets below are centred vertically.
ui.with_layout(egui::Layout::top_down(egui::Align::Center), |ui| {
ui.add_space(20.0); // Add vertical spacing before the counter.
// Display the current counter value using a large font size.
ui.label(egui::RichText::new(self.count.to_string()).size(40.0));
ui.add_space(10.0); // Add some space before the buttons.
// Create a horizontal row for the two buttons.
ui.horizontal(|ui| {
// "Decrease" button
if ui.button("Decrease").clicked() {
self.count -= 1; // Reduce count by 1 when clicked.
}
// "Increase" button
if ui.button("Increase").clicked() {
self.count += 1; // Increase count by 1 when clicked.
}
});
ui.add_space(10.0); // Add spacing before the slider.
// Add a slider to adjust the count within a fixed range (0 to 100).
// The .text("Count") label appears beside the slider.
ui.add(egui::Slider::new(&mut self.count, 0..=100).text("Count"));
});
});
}
}
// Entry point of the application
fn main() -> eframe::Result<()> {
// Define options for the native window, such as size and style.
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([300.0, 200.0]), // Set initial window size.
..Default::default()
};
// Launch the native app.
// Arguments:
// - "Counter App": the window title.
// - `options`: window configuration defined above.
// - A closure that creates and returns an instance of `CounterApp`.
eframe::run_native(
"Counter App",
options,
Box::new(|_cc| Ok(Box::new(CounterApp::default()))),
)
}
  1. Application State: The CounterApp struct holds a single integer called count. This field is the “single source of truth” for our application. All widgets will read from or write to this value.
  2. Displaying Data: We use ui.label() to display the current count. The format! macro is used to convert the i32 into a String that the label can show. egui::RichText is used to easily style the text with a larger font size.
  3. Responding to Button Clicks: The pattern “if ui.button("...").clicked()“ is the core of event handling in egui. The .clicked() method returns true for the single frame in which the button is clicked, allowing us to run code in response — in this case, modifying self.count. The ui.horizontal block ensures the buttons are placed side-by-side.
  4. Two-Way Data Binding with a Slider: The egui::Slider is a powerful example of egui’s immediate mode data handling. By passing a mutable reference (&mut self.count), we allow the slider to both read the current value of count to set its position and write a new value back to count when the user interacts with it. This creates a seamless two-way binding with a single line of code.

egui provides a rich set of built-in widgets and flexible layout options, enabling developers to construct interactive user interfaces with relative ease. Its immediate mode nature fundamentally shapes how these widgets are used and how user interactions are handled.

The egui::Ui object is central to adding and arranging widgets within an egui application. It acts as the primary interface for declaring UI elements during each frame’s update cycle.

egui offers a comprehensive collection of common widgets:

  • Labels: Display static or dynamic text using ui.label("Some text") or ui.label(&my_string_variable).
  • Buttons: Create interactive buttons with ui.button("Click Me!").
  • Text Inputs: Allow users to enter and modify text with ui.text_edit_singleline(&mut my_string) for single-line input.
  • Sliders: Provide a way to select a value within a defined range using ui.add(egui::Slider::new(&mut my_f32, 0.0..=100.0)).
  • Drag Values: Similar to text inputs but allow value changes by dragging the mouse, often used for numerical adjustments: ui.add(egui::DragValue::new(&mut my_f32)).
  • Checkboxes: Enable boolean selections with ui.checkbox(&mut my_boolean, "Enable Feature").

For text formatting, egui provides helper methods on the Ui struct that apply default styles, similar to HTML tags: label() for plain text, heading() for larger text, strong() for bold, code() for inline code snippets, and hyperlink() for clickable URLs.

Layout in egui is managed through various panel types and horizontal/vertical containers. CentralPanel, SidePanel, TopBottomPanel, and Window are used to define large regions of the application window. For arranging widgets within these regions, ui.horizontal(|ui| {... }) and ui.vertical(|ui| {... }) are used to group elements side-by-side or stacked vertically, respectively. egui also supports responsive layouts based on breakpoints, allowing UI adaptation to different screen sizes.

Here is an expanded example of a simple counter application, demonstrating core widgets and layout:

#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use eframe::{egui, App, Frame};
use egui::{CentralPanel, Context, Ui, RichText, Color32};
// Import RichText and Color32 for styling
/// Defines the application's mutable state.
struct MyApp {
counter: i32,
input_text: String,
is_checked: bool,
slider_value: f32,
}
impl Default for MyApp {
fn default() -> Self {
Self {
counter: 0,
input_text: "Edit me!".to_owned(),
is_checked: false,
slider_value: 50.0,
}
}
}
impl App for MyApp {
fn update(&mut self, ctx: &Context, _frame: &mut Frame) {
CentralPanel::default().show(ctx, |ui| {
ui.heading("egui Widgets and Layout Demo");
ui.add_space(10.0); // Add some vertical spacing
// Horizontal layout for counter buttons and label
ui.horizontal(|ui| {
ui.label("Counter:");
if ui.button(RichText::new("").color(Color32::RED)).clicked() {
self.counter -= 1;
}
ui.label(RichText::new(self.counter.to_string()).strong());
// Display counter value
if ui.button(RichText::new("+").color(Color32::GREEN)).clicked() {
self.counter += 1;
}
});
ui.add_space(20.0);
// Text input and display
ui.horizontal(|ui| {
ui.label("Your Message:");
ui.text_edit_singleline(&mut self.input_text);
});
ui.label(format!("Echo: {}", self.input_text));
ui.add_space(20.0);
// Checkbox
ui.checkbox(&mut self.is_checked, "Enable Feature");
if self.is_checked {
ui.label("Feature is enabled!");
} else {
ui.label("Feature is disabled.");
}
ui.add_space(20.0);
// Slider
ui.add(egui::Slider::new(&mut self.slider_value, 0.0..=100.0)
.text("Adjust Value"));
ui.label(format!("Slider value: {:.1}", self.slider_value));
ui.add_space(20.0);
// Hyperlink example
ui.hyperlink("https://www.egui.rs/#demo").on_hover_text("Visit egui demo");
});
}
}
fn main() -> eframe::Result<()> {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default().with_inner_size([400.0, 500.0]),
..Default::default()
};
eframe::run_native(
"My egui", // Window title
options,
Box::new(|_cc| Ok(Box::new(MyApp::default()))),
)
}

On execution, this gives the output as illustrated in Figure 3.

Figure 3. The egui demo with labels, buttons, text input, sliders, drag values and checkboxes.

The concept of event-driven programming is fundamental to GUI applications, where the application responds to user actions or system events. In egui, as an immediate mode GUI, event handling is approached differently from traditional retained mode frameworks.

In egui, all egui Widgets are essentially function calls that return a Response struct. Instead of registering explicit callbacks or listeners that are invoked when an event occurs, developers directly query the Response object returned by a widget function during each frame’s update cycle. This Response struct provides methods such as .clicked(), .hovered(), and .dragged(), which return a boolean indicating whether the corresponding interaction occurred in the current frame.

So, to handle a button click, you can simply call ui.button("Label").clicked() within the update method. If this method returns true, the button was clicked, and the associated action can be performed immediately. This direct query mechanism means there are no separate event listener registrations or complex callback chains to manage, simplifying the code for many common interactions.

Consider the button click handling already present in the earlier “Hello World” example. The if ui.button("Click Me!").clicked() {... } construct directly demonstrates this immediate mode event handling. The condition clicked() is evaluated every frame, and if true, the counter is incremented. Similarly, for a slider, egui reads the current value and updates it if the mouse is dragging the slider, without explicitly storing the slider’s value internally; the application is responsible for managing the underlying state.

This approach to event handling in immediate mode, relying on direct querying of widget responses, offers a streamlined and often more intuitive way to manage user interactions compared to the traditional callback-based systems of retained mode GUIs. It reduces the boilerplate code associated with event listener registration and the complexities of managing long chains of callbacks. By checking for interactions directly in the same frame that widgets are drawn, the code for simple interactions becomes more concise and easier to understand. This can lead to a more direct and readable flow for UI logic, as the interaction logic is co-located with the UI declaration, which can be particularly advantageous for students learning GUI concepts.

Advanced egui Applications: From State to Custom Graphics

Section titled “Advanced egui Applications: From State to Custom Graphics”

The previous examples introduced the fundamental building blocks of an egui application: setting up a project, defining application state in a struct, and adding widgets within the update loop. This section builds upon that foundation with a series of progressively more complex, standalone applications. Each example introduces new concepts, moving from simple state management to interactive custom drawing, a powerful feature of immediate mode GUIs.

Example 1: A Simple State Machine - The Traffic Light

Section titled “Example 1: A Simple State Machine - The Traffic Light”

Our first advanced example demonstrates how to manage application state using an enum. The user interface will change its appearance based on the current state, a common requirement in GUI applications. We will simulate a traffic light that cycles through its states when a button is pressed.

Key Concepts Introduced:

  • State Management with Enums: Using a Rust enum to represent distinct application states.
  • Conditional Rendering: Using a match statement to render different UI elements based on the current state.
  • Basic Custom Drawing: Introducing the ui.painter() method to draw simple, colored shapes.

ui.painter() in egui is the low-level drawing interface you can use to manually paint shapes, text, and lines inside a widget’s allocated space. It returns a painter object that lets you draw directly onto the area currently being handled by the UI. It is similar to having a “canvas” for the part of the interface you are inside. The painter does not create widgets. Instead, it draws shapes after the layout is done and before the frame is rendered. Think of it as a vector-graphics drawing API inside egui. The painter allows you to issue drawing commands such as:

  • Draw lines
  • Draw circles
  • Draw filled rectangles
  • Draw paths and curves
  • Draw images (if a texture is registered)
  • Draw text at a specific position

Everything is placed in screen coordinates, not layout coordinates, where (0,0) is the top-left of the window. The coordinates you pass must be absolute positions on the screen. A helper method, ui.min_rect().min, tells you the top-left of your allocated region. Note that the ui.painter() only draws — for interaction, you handle input with the normal egui APIs and then draw with the painter.

Figure 5. The Traffic Lights Example The code will cycle through the states red->green->amber->red etc.

Cargo.toml:

Cargo.toml
[package]
name = "egui"
version = "0.1.0"
edition = "2024"
[dependencies]
eframe = "0.29.0"
egui_plot = "0.29.0"

main.rs:

#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
// Hide console window on Windows in release
use eframe::egui;
use egui::{Color32, Pos2, Rect, Rounding, Sense, Stroke, Vec2};
// Enum to represent the state of our traffic light
#[derive(PartialEq, Clone, Copy)]
enum TrafficLight {
Red,
Yellow,
Green,
}
struct TrafficLightApp {
current_light: TrafficLight,
}
impl Default for TrafficLightApp {
fn default() -> Self {
Self {
current_light: TrafficLight::Red,
}
}
}
impl eframe::App for TrafficLightApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
egui::CentralPanel::default().show(ctx, |ui| {
ui.heading("Traffic Light Simulator");
ui.add_space(20.0);
// Use a vertical layout to center our traffic light drawing
ui.with_layout(egui::Layout::top_down(egui::Align::Center), |ui| {
// Allocate space for our drawing. Use this Rect to position the lights.
// Sense::hover() does not check for clicks or drags. Could have used
// Sense::nothing() instead in this case.
let (response, painter) =
ui.allocate_painter(Vec2::new(100.0, 300.0), Sense::hover());
let rect = response.rect;
// Draw the black background of the traffic light
painter.rect_filled(rect, Rounding::same(10.0), Color32::DARK_GRAY);
// Define colors for the lights
let red_color = if self.current_light == TrafficLight::Red {
Color32::RED
} else {
Color32::from_rgb(60, 0, 0)
};
let yellow_color = if self.current_light == TrafficLight::Yellow {
Color32::YELLOW
} else {
Color32::from_rgb(60, 60, 0)
};
let green_color = if self.current_light == TrafficLight::Green {
Color32::GREEN
} else {
Color32::from_rgb(0, 60, 0)
};
// Calculate positions and draw the three lights
let light_radius = rect.width() / 2.0 - 10.0;
let center_top = rect.center_top() + Vec2::new(0.0, rect.width() / 2.0);
painter.circle_filled(center_top, light_radius, red_color);
let center_middle = rect.center();
painter.circle_filled(center_middle, light_radius, yellow_color);
let center_bottom = rect.center_bottom() -
Vec2::new(0.0, rect.width() / 2.0);
painter.circle_filled(center_bottom, light_radius, green_color);
ui.add_space(20.0);
// Button to change the state
if ui.button("Next State").clicked() {
self.current_light = match self.current_light {
TrafficLight::Red => TrafficLight::Green,
TrafficLight::Green => TrafficLight::Yellow,
TrafficLight::Yellow => TrafficLight::Red,
};
}
});
});
}
}
fn main() -> eframe::Result<()> {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default().with_inner_size([300.0, 500.0]),
..Default::default()
};
eframe::run_native(
"Traffic Light",
options,
Box::new(|_cc| Ok(Box::new(TrafficLightApp::default()))),
)
}
  1. TrafficLight Enum: We define a simple enum with three variants: Red, Yellow, and Green. This makes our state explicit and type-safe. The current_light field in TrafficLightApp holds the single source of truth for the application’s state.
  2. ui.allocate_painter: This is a key function. It allocates a rectangular region of a given size (Vec2) for custom painting and returns a Painter object and a Response. The Painter is our tool for drawing shapes.
  3. Drawing Shapes: We use painter.rect_filled() to draw the grey background and painter.circle_filled() for the lights. The position and radius of each circle are calculated based on the rect of the allocated space.
  4. Conditional Colors: The color of each light is determined by an if expression that checks self.current_light. The “on” light gets a bright color, while the “off” lights get a dim, dark color. Because this logic runs every frame, the drawing is always in sync with the state.
  5. State Transition: The match statement inside the if ui.button(...).clicked() block defines the state transitions. When the button is clicked, we update self.current_light to the next state in the sequence. This new state will be used in the very next frame to redraw the UI.

This example moves into more complex user interaction. We will create a simple drawing application where the user can draw lines with their mouse. This requires handling mouse input, such as clicks and drags, and storing a history of drawn shapes.

Key Concepts Introduced:

  • The Painter API: Deeper use of the Painter to draw arbitrary shapes.
  • Input Sensing: Using Sense to make a UI area interactive.
  • Handling Mouse Input: Checking the Response object for drag events (.drag_started(), .dragged()).
  • Storing Drawing State: Using a Vec to store the points of the lines drawn by the user.

Figure 6. The basic drawing application with color picker

#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
// Hide console window on Windows in release
use eframe::egui;
use egui::{Color32, Pos2, Sense, Stroke};
struct DrawingApp {
// Each inner Vec is a single continuous line
lines: Vec<Vec<Pos2>>,
stroke: Stroke,
}
impl Default for DrawingApp {
fn default() -> Self {
Self {
lines: Vec::new(),
stroke: Stroke::new(2.0, Color32::from_rgb(25, 200, 100)),
}
}
}
impl eframe::App for DrawingApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// Add a Top/Bottom panel for controls
egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
ui.horizontal(|ui| {
ui.label("Controls:");
if ui.button("Clear").clicked() {
self.lines.clear();
}
// Allow user to change the stroke color
egui::widgets::color_picker::color_edit_button_srgba(ui,
&mut self.stroke.color, egui::widgets::color_picker::Alpha::Opaque);
});
});
egui::CentralPanel::default().show(ctx, |ui| {
// Allocate the rest of the window for our drawing canvas
// Use Sense::drag() to respond to hovers, clicks and mouse drags
let (response, painter) =
ui.allocate_painter(ui.available_size(), Sense::drag());
// Handle user input
if response.drag_started() {
// User started drawing a new line
self.lines.push(Vec::new());
}
if response.dragged() {
// User is actively drawing
if let Some(pos) = response.interact_pointer_pos() {
if let Some(last_line) = self.lines.last_mut() {
last_line.push(pos);
}
}
}
// Draw the existing lines
for line in &self.lines {
if line.len() > 1 {
let shape = egui::Shape::line(line.clone(), self.stroke);
painter.add(shape);
}
}
});
}
}
fn main() -> eframe::Result<()> {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default().with_inner_size([800.0, 600.0]),
..Default::default()
};
eframe::run_native(
"Drawing Pad",
options,
Box::new(|_cc| Ok(Box::new(DrawingApp::default()))),
)
}
  1. Data Structure: We use lines: Vec&lt;Vec&lt;Pos2>> to store the drawing. The outer Vec holds all the separate lines the user has drawn. Each inner Vec&lt;Pos2> represents a single, continuous line made up of many points (Pos2).
  2. Sensing Drags: When we allocate the painter with ui.allocate_painter, we pass Sense::drag(). This tells egui that this area should be sensitive to mouse drag interactions.
  3. Input Handling:
    • response.drag_started(): This returns true for the single frame when the user begins dragging. We use this event to add a new, empty line to self.lines.
    • response.dragged(): This returns true for every frame the user continues to drag. Inside this block, we get the current pointer position using response.interact_pointer_pos() and push it to the last line in our self.lines vector.
  4. Persistent Drawing: In the rendering section of the update loop, we iterate over all the lines stored in our state. For each line, we create an egui::Shape::line and add it to the painter. Because we are drawing from our persistent state every frame, the drawing remains on the screen.
  5. Controls: A TopBottomPanel holds a “Clear” button, which simply calls self.lines.clear() to erase the drawing by emptying the state. We also add a color picker widget that directly modifies the color field of our stroke state.

This final example combines widgets for controlling parameters with a more complex custom-drawn visualisation. We will plot a sine wave and allow the user to change its amplitude and frequency in real-time using sliders. This requires mapping data coordinates to screen coordinates.

Key Concepts Introduced:

  • Layout Panels: Using SidePanel and CentralPanel to structure the application.
  • Coordinate Transformation: Mapping mathematical coordinates to screen pixel coordinates.
  • Dynamic Visualisation: Creating a visualisation that updates in real-time based on widget interactions.

Figure 7: The Sine Wave Plotter

#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
// Hide console window on Windows in release
use eframe::egui;
use egui::{Color32, Pos2, Rect, Sense, Stroke, Vec2};
use std::f32::consts::PI;
struct PlotterApp {
amplitude: f32,
frequency: f32,
phase: f32,
}
impl Default for PlotterApp {
fn default() -> Self {
Self {
amplitude: 50.0,
frequency: 2.0,
phase: 0.0,
}
}
}
impl eframe::App for PlotterApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// Panel for sliders
egui::SidePanel::left("controls_panel").show(ctx, |ui| {
ui.heading("Controls");
ui.add(egui::Slider::new(&mut self.amplitude,
1.0..=100.0).text("Amplitude"));
ui.add(egui::Slider::new(&mut self.frequency,
0.1..=10.0).text("Frequency"));
ui.add(egui::Slider::new(&mut self.phase, 0.0..=2.0 * PI).text("Phase"));
});
// Main panel for the plot
egui::CentralPanel::default().show(ctx, |ui| {
ui.heading("Sine Wave Plotter");
let (response, painter) =
ui.allocate_painter(ui.available_size_before_wrap(), Sense::hover());
let plot_rect = response.rect;
// Draw axes
self.draw_axes(&painter, &plot_rect);
// Generate and draw the sine wave
let points = self.generate_sine_points(&plot_rect);
let stroke = Stroke::new(2.0, Color32::LIGHT_BLUE);
let shape = egui::Shape::line(points, stroke);
painter.add(shape);
});
}
}
impl PlotterApp {
fn draw_axes(&self, painter: &egui::Painter, rect: &Rect) {
let stroke = Stroke::new(1.0, Color32::DARK_GRAY);
// Y-axis (vertical)
painter.line_segment(
[rect.center_bottom(), rect.center_top()],
stroke,
);
// X-axis (horizontal)
painter.line_segment(
[rect.left_center(), rect.right_center()],
stroke,
);
}
fn generate_sine_points(&self, rect: &Rect) -> Vec<Pos2> {
let mut points = Vec::new();
let num_points = rect.width() as usize;
let origin = rect.center();
for i in 0..=num_points {
let screen_x = rect.left() + i as f32;
// Map screen x to a value between 0 and 2*PI*frequency
let normalized_x = (i as f32 / rect.width()) * 2.0 * PI * self.frequency;
// Calculate sine value and apply phase and amplitude
let sin_y = (normalized_x + self.phase).sin();
let screen_y = origin.y - sin_y * self.amplitude;
points.push(Pos2::new(screen_x, screen_y));
}
points
}
}
fn main() -> eframe::Result<()> {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default().with_inner_size([1000.0, 600.0]),
..Default::default()
};
eframe::run_native(
"EEN1097 Sine Wave Plotter",
options,
Box::new(|_cc| Ok(Box::new(PlotterApp::default()))),
)
}
  1. Layout: The UI is split into two main parts. A SidePanel on the left contains the sliders for controlling the sine wave parameters. The CentralPanel takes up the remaining space and is dedicated to the plot itself.
  2. State: The application state (amplitude, frequency, phase) is now directly controlled by the Slider widgets. When a user drags a slider, egui modifies the value in our struct.
  3. Coordinate Transformation: The core of this example is in the generate_sine_points method.
    • We iterate through the horizontal pixels of our plotting area (plot_rect).
    • For each pixel column (screen_x), we normalize it to a value appropriate for a sine function. This maps the width of the rectangle to a certain number of sine wave cycles.
    • We calculate sin(x) and then transform this mathematical result back into screen coordinates. The amplitude scales the y-value, and we subtract from origin.y because screen coordinates have Y increasing downwards.
  4. Immediate Mode in Action: Every time the user moves a slider, the state in PlotterApp changes. On the very next frame, the update function is called, and generate_sine_points is re-run with the new values. This generates a completely new set of points, which are then rendered by the painter. The result is a smooth, real-time update to the visualisation, perfectly demonstrating the power and simplicity of the immediate mode paradigm for interactive applications.

The ability to build responsive, high-fidelity interfaces is no longer reserved for desktop or web applications. As embedded systems evolve into “smart nodes”, the demand for sophisticated user interaction at the edge has grown. Whether you are designing a sleek control panel for industrial Industry 4.0 automation, a responsive interface for smart home controls, or a robust interaction layer for a modern vending machine, the frameworks and concepts discussed in this chapter provide the necessary tools.

By leveraging Rust’s performance and safety, libraries like egui allow developers to create rich, interactive experiences on resource-constrained hardware. The shift from basic character displays to full graphical interfaces enables more intuitive operation and better data visualisation directly at the point of use. As you continue to develop for edge systems, remember that the UI is the bridge between your complex backend logic and the human end user. Mastering GUI techniques ensures that your smart nodes are not only functional but also accessible and professional.