Skip to content

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

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

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 \

  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.
Code Cloze
Rust

Manual Painting in egui

Quiz
Select 0/1

Why is an `enum` particularly useful for managing a GUI state like a traffic light in Rust?

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()))),
)
}

Explanation \

  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.
Concept Match

Match the Drawing API Components

Quiz
Select 0/1

Which `Sense` variant should be passed to `allocate_painter` if the area needs to respond to mouse movement while a button is held down?

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

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 \

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

Figure X. The Signal Plotter using egui_plot

Key Concepts Introduced:

  • egui_plot dependency: How to add and version-match the crate.
  • PlotPoints and Line: 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: \

  1. PlotPoints: A collected iterator of [f64; 2] pairs. Each pair is a [x, y] data point. The collect() call converts the iterator into the type egui_plot expects.
  2. Line::new(points).name("sin"): Wraps the point series in a Line object and assigns the name shown in the legend.
  3. 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.
  4. view_aspect(2.0): Sets the width-to-height ratio of the plot area. This prevents distortion when the window is resized.
  5. Built-in interactions: Unlike the manual sine plotter in Example 3, egui_plot automatically 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).

Figure X. The Sensor Monitor Application showing live data.

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() inside update(): 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 memory
const 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: \

  1. CreationContext and thread spawning: The new() function receives &eframe::CreationContext<'_>, which provides access to the egui context and render state during startup. Threads should be spawned here rather than in Default::default(), so that they can cleanly reference the context if needed and so the ownership model is clear.
  2. Channel ownership: tx (the Sender) is moved into the background thread. rx (the Receiver) 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.
  3. Non-blocking drain: try_recv() returns Ok(value) if a message is waiting and Err immediately if the channel is empty. The while let loop drains all accumulated messages in a single frame, so no reading is ever lost if the GUI runs slower than the sensor.
  4. 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.
  5. Clean thread shutdown: When the GUI closes, the Receiver is dropped. The next tx.send() in the background thread returns Err, and the thread exits its loop and terminates naturally - there are no dangling threads or explicit join handles required.

Concept Match

egui Core Concepts

Code Cloze
Rust

Integrating egui_plot

Quiz
Select 0/1

What does `ui.button('OK').clicked()` return?

Quiz
Select 0/1

Why must long-running tasks (such as reading a hardware sensor) be moved to a background thread rather than called directly inside update()?

Quiz
Select 0/3

Which of the following are correct steps in the background-thread pattern for live sensor data in an egui application? Select all that apply.

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.