Green Computer congress.

Building Native GUIs in Rust with eGUI

Cover Image for Building Native GUIs in Rust with eGUI
Karthick Srinivasan Thiruvenkatam
Karthick Srinivasan Thiruvenkatam

Rust is often associated with systems programming, CLI tools, and high-performance backends. However, the Rust GUI ecosystem is vibrant and growing. Among the many options (Iced, Tauri, Slint, Dio), eGUI stands out for its simplicity, ease of use, and "immediate mode" philosophy.

In this tutorial, we will build a native desktop application from scratch using eGUI.

Why eGUI?

eGUI (Easy GUI) is an immediate mode GUI library written in Rust.

  • Immediate Mode: Unlike "retained mode" GUIs (like the DOM or Qt), where you create widget objects and update their state, immediate mode GUIs rebuild the entire interface every frame. This sounds expensive, but for simple to moderately complex apps, it's incredibly fast and reduces state management headaches.
  • Cross-Platform: Runs on Windows, Mac, Linux, and even compiles to WASM for the web.
  • Rust-Native: It's written in pure Rust, offering safety and performance.

Getting Started

Prerequisites

Make sure you have Rust installed.

rustc --version

Step 1: Create a Project

cargo new my_egui_app
cd my_egui_app

Add the eframe dependency. eframe is the official framework library that wraps egui for easy desktop and web integration.

[dependencies]
eframe = "0.24.0" # Check for the latest version
serde = { version = "1", features = ["derive"] } # Optional: for saving state
serde_json = "1"

Step 2: The "Hello World" of GUI

In src/main.rs, we need to define our application state and implement the eframe::App trait.

use eframe::egui;

fn main() -> Result<(), eframe::Error> {
    let options = eframe::NativeOptions {
        initial_window_size: Some(egui::vec2(320.0, 240.0)),
        ..Default::default()
    };
    
    eframe::run_native(
        "My First eGUI App",
        options,
        Box::new(|_cc| Box::new(MyApp::default())),
    )
}

struct MyApp {
    name: String,
    age: u32,
}

impl Default for MyApp {
    fn default() -> Self {
        Self {
            name: "Arthur".to_owned(),
            age: 42,
        }
    }
}

impl eframe::App for MyApp {
    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
        egui::CentralPanel::default().show(ctx, |ui| {
            ui.heading("My Application");
            
            ui.horizontal(|ui| {
                ui.label("Your name: ");
                ui.text_edit_singleline(&mut self.name);
            });

            ui.add(egui::Slider::new(&mut self.age, 0..=120).text("age"));

            if ui.button("Click each year").clicked() {
                self.age += 1;
            }

            ui.label(format!("Hello '{}', age {}", self.name, self.age));
        });
    }
}

Understanding the Code

  1. MyApp Struct: This holds your application state. In Rust, state management is explicit.
  2. update function: This is the heart of eGUI. It runs every frame (60 times a second ideally).
  3. ui Helper: The ui object is used to add widgets. ui.label, ui.button, etc., are added in the order they appear in code.

Step 3: Layouts and Widgets

Real applications need more than just a vertical list of controls. eGUI provides powerful layout tools.

Columns and Grids

ui.columns(2, |columns| {
    columns[0].label("First Column");
    columns[1].button("Second Column");
});

ui.separator();

egui::Grid::new("my_grid").show(ui, |ui| {
    ui.label("Name");
    ui.text_edit_singleline(&mut self.name);
    ui.end_row();

    ui.label("Age");
    ui.add(egui::DragValue::new(&mut self.age));
    ui.end_row();
});

Panels

A standard desktop app usually has a menu bar, a side panel, and a central area.

impl eframe::App for MyApp {
    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
        // Top Menu Bar
        egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
            egui::menu::bar(ui, |ui| {
                ui.menu_button("File", |ui| {
                    if ui.button("Quit").clicked() {
                        _frame.close();
                    }
                });
            });
        });

        // Side Panel
        egui::SidePanel::left("side_panel").show(ctx, |ui| {
            ui.heading("Side Panel");
            ui.label("Useful for navigation or settings.");
        });

        // Central Panel (Remaining space)
        egui::CentralPanel::default().show(ctx, |ui| {
            ui.heading("Main Content");
            ui.label("The rest of the window content goes here.");
        });
    }
}

Advanced Example: A Todo List

Let's build something functional: A Todo List app that persists data.

1. Define State with Serialization

Update Cargo.toml to include serde and serde_json if you haven't.

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
struct TodoApp {
    todos: Vec<String>,
    new_todo: String,
}

impl Default for TodoApp {
    fn default() -> Self {
        Self {
            todos: Vec::new(),
            new_todo: String::new(),
        }
    }
}

2. Implement Persistence

eGUI has built-in support for saving state to disk using eframe::Storage.

impl TodoApp {
    fn new(cc: &eframe::CreationContext<'_>) -> Self {
        if let Some(storage) = cc.storage {
            return eframe::get_value(storage, eframe::APP_KEY).unwrap_or_default();
        }
        Default::default()
    }
}

impl eframe::App for TodoApp {
    fn save(&mut self, storage: &mut dyn eframe::Storage) {
        eframe::set_value(storage, eframe::APP_KEY, self);
    }
    // ... update ...
}

3. The UI Logic

impl eframe::App for TodoApp {
    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
        egui::CentralPanel::default().show(ctx, |ui| {
            ui.heading("Rust Todo List");

            ui.horizontal(|ui| {
                ui.label("New Item:");
                ui.text_edit_singleline(&mut self.new_todo);
                if ui.button("Add").clicked() {
                    if !self.new_todo.is_empty() {
                        self.todos.push(self.new_todo.clone());
                        self.new_todo.clear();
                    }
                }
            });

            ui.separator();

            ui.heading("Your Tasks:");
            
            // We need to collect indices to remove to avoid mutable borrow errors
            let mut to_remove = Vec::new();
            
            egui::ScrollArea::vertical().show(ui, |ui| {
                for (index, todo) in self.todos.iter().enumerate() {
                    ui.horizontal(|ui| {
                        ui.label(format!("• {}", todo));
                        if ui.button("❌").clicked() {
                            to_remove.push(index);
                        }
                    });
                }
            });

            // Remove deleted items
            for index in to_remove.iter().rev() {
                self.todos.remove(*index);
            }
        });
    }
}

Styling

eGUI comes with a default dark/light theme, but you can customize it fully.

let mut visuals = egui::Visuals::dark();
visuals.widgets.noninteractive.bg_fill = egui::Color32::from_rgb(10, 10, 20); // Dark blue background
ctx.set_visuals(visuals);

Conclusion

eGUI is a fantastic library for getting tools up and running quickly. It lacks the complex styling engines of CSS-based frameworks, but makes up for it in raw performance and development speed.

If you are building internal tools, debug interfaces, or scientific visualizations, eGUI is arguably the best choice in the Rust ecosystem today.