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.

Introduction to Rust GUI Development
Section titled “Introduction to Rust GUI Development”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 Name | Primary Paradigm | Cross-Platform Support (Desktop) | Key Differentiator/Philosophy | Maturity/Stability | Learning Curve (Rust-specific) |
|---|---|---|---|---|---|
| egui | Immediate Mode | Windows, Linux, macOS | Simplicity, rapid iteration, debugging, visualisation | Evolving/Stable | Moderate |
| Tauri | Webview | Windows, Linux, macOS | Lightweight, secure, web-tech integration, Rust backend | Stable | Low (for frontend), Moderate (for Rust IPC) |
| Dioxus | Declarative (React-like) | Windows, Linux, macOS | Fullstack, hot-reloading, native Rust UI, ergonomic state | Evolving | Moderate |
| Slint | Declarative (DSL) | Windows, Linux, macOS | Designer-friendly, native compilation, low resource usage | Evolving/Stable | Moderate |
| GTK-rs | Retained Mode | Linux (Primary), Windows, macOS | Native Gnome look, mature bindings, comprehensive widgets | Mature | Steep |
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.

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.
Foundational Concepts of egui
Section titled “Foundational Concepts of egui”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:
- 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 aCargo.tomlfile and asrc/main.rsfile as is usual.
PS C:\temp> cargo new my_egui Creating binary (application) `my_egui` packagenote: see more `Cargo.toml` keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html- Add
eframeas a dependency: The easiest way to add egui is to use cargo to update your cargo.toml file as follows:
PS C:\temp> cargo add eframeWhich 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()))), )}
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.

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.
Rust Closures (Following on from C++)
Section titled “Rust Closures (Following on from C++)”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 thethispointer by value.[&a, b]capturesaby reference andbby 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:
Fn(The “Immutable Borrow”): The closure only needs to read captured variables.FnMut(The “Mutable Borrow”): The closure needs to mutate (change) captured variables. This is the most common trait in egui.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.
Example in egui: Layout and State
Section titled “Example in egui: Layout and State”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 applicationstruct 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:
- We call
ui.horizontal(...). - We pass it a closure:
|ui_inner| { ... }. - The
horizontalfunction executes. - Inside
horizontal, it creates a new&mut egui::Ui(let’s call ithorizontal_ui). This newUiobject is configured to make widgets line up side-by-side. - The
horizontalfunction then calls our closure and passes itshorizontal_uias the first and only argument. - Our closure receives that argument and names it
ui_inner. - We can then call methods on
ui_inner(likeui_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.
Working with Standard Widgets
Section titled “Working with Standard Widgets”A Simple Counter Application
Section titled “A Simple Counter Application”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.horizontalto arrange widgets.

The Code
Section titled “The Code”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 structurestruct 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 applicationfn 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()))), )}Explanation
Section titled “Explanation”- Application State: The
CounterAppstruct holds a single integer calledcount. This field is the “single source of truth” for our application. All widgets will read from or write to this value. - Displaying Data: We use
ui.label()to display the current count. Theformat!macro is used to convert thei32into aStringthat the label can show.egui::RichTextis used to easily style the text with a larger font size. - Responding to Button Clicks: The pattern “
if ui.button("...").clicked()“ is the core of event handling inegui. The.clicked()method returnstruefor the single frame in which the button is clicked, allowing us to run code in response — in this case, modifyingself.count. Theui.horizontalblock ensures the buttons are placed side-by-side. - Two-Way Data Binding with a Slider: The
egui::Slideris a powerful example ofegui’s immediate mode data handling. By passing a mutable reference (&mut self.count), we allow the slider to both read the current value ofcountto set its position and write a new value back tocountwhen the user interacts with it. This creates a seamless two-way binding with a single line of code.
egui Widgets and Basic Interaction
Section titled “egui Widgets and Basic Interaction”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.
Core Widgets and Layouts
Section titled “Core Widgets and Layouts”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")orui.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.

Event Handling in Immediate Mode
Section titled “Event Handling in Immediate Mode”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
enumto represent distinct application states. - Conditional Rendering: Using a
matchstatement 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.

The Code
Section titled “The Code”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()))), )}Explanation
Section titled “Explanation”TrafficLightEnum: We define a simpleenumwith three variants:Red,Yellow, andGreen. This makes our state explicit and type-safe. Thecurrent_lightfield inTrafficLightAppholds the single source of truth for the application’s state.ui.allocate_painter: This is a key function. It allocates a rectangular region of a given size (Vec2) for custom painting and returns aPainterobject and aResponse. ThePainteris our tool for drawing shapes.- Drawing Shapes: We use
painter.rect_filled()to draw the grey background andpainter.circle_filled()for the lights. The position and radius of each circle are calculated based on therectof the allocated space. - Conditional Colors: The color of each light is determined by an
ifexpression that checksself.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. - State Transition: The
matchstatement inside theif ui.button(...).clicked()block defines the state transitions. When the button is clicked, we updateself.current_lightto the next state in the sequence. This new state will be used in the very next frame to redraw the UI.
Example 2: Interactive Drawing Pad
Section titled “Example 2: Interactive Drawing Pad”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
PainterAPI: Deeper use of thePainterto draw arbitrary shapes. - Input Sensing: Using
Senseto make a UI area interactive. - Handling Mouse Input: Checking the
Responseobject for drag events (.drag_started(),.dragged()). - Storing Drawing State: Using a
Vecto store the points of the lines drawn by the user.

The Code
Section titled “The Code”#![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()))), )}Explanation
Section titled “Explanation”- Data Structure: We use lines:
Vec<Vec<Pos2>>to store the drawing. The outerVecholds all the separate lines the user has drawn. Each innerVec<Pos2>represents a single, continuous line made up of many points (Pos2). - Sensing Drags: When we allocate the painter with
ui.allocate_painter, we passSense::drag(). This tellseguithat this area should be sensitive to mouse drag interactions. - Input Handling:
response.drag_started(): This returnstruefor the single frame when the user begins dragging. We use this event to add a new, empty line toself.lines.response.dragged(): This returns true for every frame the user continues to drag. Inside this block, we get the current pointer position usingresponse.interact_pointer_pos()and push it to the last line in ourself.linesvector.
- 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::lineand add it to thepainter. Because we are drawing from our persistent state every frame, the drawing remains on the screen. - Controls: A
TopBottomPanelholds a “Clear” button, which simply callsself.lines.clear()to erase the drawing by emptying the state. We also add a color picker widget that directly modifies thecolorfield of ourstrokestate.
Example 3: Interactive Sine Wave Plotter
Section titled “Example 3: Interactive Sine Wave Plotter”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.

The Code
Section titled “The Code”#![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()))), )}Explanation
Section titled “Explanation”- Layout: The UI is split into two main parts. A
SidePanelon the left contains the sliders for controlling the sine wave parameters. TheCentralPaneltakes up the remaining space and is dedicated to the plot itself. - State: The application state (
amplitude,frequency,phase) is now directly controlled by the Slider widgets. When a user drags a slider,eguimodifies the value in our struct. - Coordinate Transformation: The core of this example is in the
generate_sine_pointsmethod.- 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. Theamplitudescales the y-value, and we subtract fromorigin.ybecause screen coordinates have Y increasing downwards.
- We iterate through the horizontal pixels of our plotting area (
- Immediate Mode in Action: Every time the user moves a slider, the state in
PlotterAppchanges. On the very next frame, theupdatefunction is called, andgenerate_sine_pointsis 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.
Conclusion: Rich UIs for the Edge
Section titled “Conclusion: Rich UIs for the Edge”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.
© 2026 Derek Molloy, Dublin City University. All rights reserved.