9.4 From State to Custom Graphics
This content is a draft and will not be included in production builds.

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
Cargo.toml:
[package]name = "egui"version = "0.1.0"edition = "2024"
[dependencies]eframe = "0.32.0"Version normalised to 0.32.0 to match all other examples. The egui_plot dependency was removed here as it is not used in the traffic light code; it is introduced properly in Example 4.
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 \
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.
🧩 Knowledge Check
Section titled “🧩 Knowledge Check”Manual Painting in egui
Why is an `enum` particularly useful for managing a GUI state like a traffic light in Rust?
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 \
- 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.
🧩 Knowledge Check
Section titled “🧩 Knowledge Check”Match the Drawing API Components
Which `Sense` variant should be passed to `allocate_painter` if the area needs to respond to mouse movement while a button is held down?
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 \
#![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 \
- 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.
Example 4: Signal Plotter with egui_plot
Section titled “Example 4: Signal Plotter with egui_plot”The manual coordinate-transformation approach in Example 3 is instructive, but the egui_plot crate provides a far more capable plotting widget with built-in pan, zoom, multiple series, legends, and automatic axis scaling. It is the recommended approach whenever the primary goal is data visualisation rather than bespoke rendering.

egui_plot
Key Concepts Introduced:
egui_plotdependency: How to add and version-match the crate.PlotPointsandLine: The types used to define a data series.Plot::new().show(): The immediate-mode plot widget.- Built-in interactivity: Pan and zoom provided for free.
The egui_plot crate version must match the egui version bundled with eframe. For eframe = "0.32.0", which bundles egui = "0.31", the correct version is:
[package]name = "signal_plotter"version = "0.1.0"edition = "2024"
[dependencies]eframe = "0.32.0"egui_plot = "0.31.0"#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use eframe::egui;use egui_plot::{Legend, Line, Plot, PlotPoints};use std::f64::consts::TAU;
struct SignalPlotApp { frequency: f64, phase: f64,}
impl Default for SignalPlotApp { fn default() -> Self { Self { frequency: 1.0, phase: 0.0 } }}
impl eframe::App for SignalPlotApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { egui::SidePanel::left("controls").show(ctx, |ui| { ui.heading("Controls"); ui.add(egui::Slider::new(&mut self.frequency, 0.1..=5.0).text("Frequency")); ui.add(egui::Slider::new(&mut self.phase, 0.0..=TAU).text("Phase (rad)")); });
egui::CentralPanel::default().show(ctx, |ui| { ui.heading("Signal Plotter (egui_plot)");
// Generate 500 evenly-spaced samples over x = 0..10 let sine: PlotPoints = (0..=500).map(|i| { let x = i as f64 * 0.02; [x, (x * self.frequency + self.phase).sin()] }).collect();
let cosine: PlotPoints = (0..=500).map(|i| { let x = i as f64 * 0.02; [x, (x * self.frequency + self.phase).cos()] }).collect();
// Plot::new() creates the widget; .show() draws it and calls the closure Plot::new("signal_plot") .view_aspect(2.0) .legend(Legend::default()) // enables the series legend .show(ui, |plot_ui| { plot_ui.line(Line::new(sine).name("sin")); plot_ui.line(Line::new(cosine).name("cos")); }); }); }}
fn main() -> eframe::Result<()> { let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default().with_inner_size([900.0, 500.0]), ..Default::default() }; eframe::run_native( "Signal Plotter", options, Box::new(|_cc| Ok(Box::new(SignalPlotApp::default()))), )}Explanation: \
PlotPoints: A collected iterator of[f64; 2]pairs. Each pair is a[x, y]data point. Thecollect()call converts the iterator into the typeegui_plotexpects.Line::new(points).name("sin"): Wraps the point series in aLineobject and assigns the name shown in the legend.Plot::new("signal_plot"): The string argument is an internal ID that egui uses to track per-plot state (zoom, pan position). Each plot in the application must have a unique ID.view_aspect(2.0): Sets the width-to-height ratio of the plot area. This prevents distortion when the window is resized.- Built-in interactions: Unlike the manual sine plotter in Example 3,
egui_plotautomatically provides mouse-driven pan and zoom, axis labels, and grid lines. These require no additional code.
Example 5: Live Sensor Monitor (Background Threads + Channels + Plot)
Section titled “Example 5: Live Sensor Monitor (Background Threads + Channels + Plot)”This example is a capstone combining everything introduced so far: a background OS thread that generates sensor data, std::sync::mpsc channels for safe inter-thread communication, ctx.request_repaint_after() to keep the plot live, and egui_plot for real-time visualisation. This pattern is the idiomatic solution for any egui edge application that reads hardware sensors (I2C, SPI, UART, network).

Key Concepts Introduced: \
eframe::CreationContext: The correct place to spawn background threads and initialise channels.mpsc::channel(): The sender lives in the background thread; the receiver lives in the app struct.try_recv()insideupdate(): Non-blocking drain prevents the GUI from stalling.ctx.request_repaint_after(): Schedules the next repaint without spinning the CPU.
[package]name = "sensor_monitor"version = "0.1.0"edition = "2024"
[dependencies]eframe = "0.31.0"egui_plot = "0.31.0"#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use eframe::egui;use egui_plot::{Line, Plot, PlotPoints};use std::sync::mpsc;use std::thread;use std::time::Duration;
// Maximum number of historical readings kept in memoryconst HISTORY_LEN: usize = 300;
struct SensorApp { receiver: mpsc::Receiver<f32>, history: Vec<f32>, latest: f32,}
impl SensorApp { // new() receives the CreationContext - the correct place to do setup // that needs the egui context (e.g., loading fonts, spawning threads). fn new(_cc: &eframe::CreationContext<'_>) -> Self { let (tx, rx) = mpsc::channel::<f32>();
// Spawn a background thread that simulates a temperature sensor. // The move keyword transfers ownership of `tx` into the thread. thread::spawn(move || { let mut t: f32 = 0.0; loop { // Simulate slow thermal drift with superimposed noise let reading = 22.0 + 3.0 * (t * 0.05).sin() + 0.5 * (t * 1.3).sin();
// If send() returns Err the GUI has closed - exit cleanly if tx.send(reading).is_err() { break; } t += 1.0; thread::sleep(Duration::from_millis(100)); // 10 Hz sensor } });
Self { receiver: rx, history: Vec::new(), latest: 0.0, } }}
impl eframe::App for SensorApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { // --- 1. Drain all pending readings without blocking --- while let Ok(v) = self.receiver.try_recv() { self.latest = v; self.history.push(v); if self.history.len() > HISTORY_LEN { self.history.remove(0); // keep only the most recent window } }
// --- 2. Schedule the next repaint to match the sensor rate --- ctx.request_repaint_after(Duration::from_millis(100));
// --- 3. Render --- egui::CentralPanel::default().show(ctx, |ui| { ui.heading("Live Sensor Monitor"); ui.horizontal(|ui| { ui.label("Latest reading:"); ui.strong(format!("{:.2} C", self.latest)); }); ui.separator();
// Convert history to PlotPoints: x = sample index, y = temperature let points: PlotPoints = self.history .iter() .enumerate() .map(|(i, &v)| [i as f64, v as f64]) .collect();
Plot::new("sensor_plot") .view_aspect(3.0) .include_y(15.0) // ensure the y-axis always shows a sensible range .include_y(30.0) .show(ui, |plot_ui| { plot_ui.line(Line::new(points).name("Temperature (C)")); }); }); }}
// Note: run_native now receives a closure that calls SensorApp::new(cc),// giving the app access to the CreationContext during initialisation.fn main() -> eframe::Result<()> { let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default().with_inner_size([800.0, 500.0]), ..Default::default() }; eframe::run_native( "Sensor Monitor", options, Box::new(|cc| Ok(Box::new(SensorApp::new(cc)))), )}Explanation: \
CreationContextand thread spawning: Thenew()function receives&eframe::CreationContext<'_>, which provides access to the egui context and render state during startup. Threads should be spawned here rather than inDefault::default(), so that they can cleanly reference the context if needed and so the ownership model is clear.- Channel ownership:
tx(theSender) is moved into the background thread.rx(theReceiver) is stored in the app struct. Rust’s ownership rules statically enforce that only the background thread can write to the channel and only the GUI thread can read from it - no mutex needed. - Non-blocking drain:
try_recv()returnsOk(value)if a message is waiting andErrimmediately if the channel is empty. Thewhile letloop drains all accumulated messages in a single frame, so no reading is ever lost if the GUI runs slower than the sensor. include_y(): Prevents the plot auto-scaling from zooming in on tiny variations in the data, which would produce a misleading visual impression of large fluctuations.- Clean thread shutdown: When the GUI closes, the
Receiveris dropped. The nexttx.send()in the background thread returnsErr, and the thread exits its loop and terminates naturally - there are no dangling threads or explicit join handles required.
🧩 Knowledge Check
Section titled “🧩 Knowledge Check”egui Core Concepts
Integrating egui_plot
What does `ui.button('OK').clicked()` return?
Why must long-running tasks (such as reading a hardware sensor) be moved to a background thread rather than called directly inside update()?
Which of the following are correct steps in the background-thread pattern for live sensor data in an egui application? Select all that apply.
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.