GUI Basics
8 examples to get you started with desktop GUI development in Rust - 6 basic and 2 intermediate.
Prerequisites
# Tauri path
npm create tauri-app@latest
# egui path
cargo add eframe egui
# iced path
cargo add icedTooling: Backend examples target Rust 1.97.0 (edition 2024). Tauri also needs Node for the frontend toolchain.
Basic Examples
1. Minimal egui window
Immediate-mode UI in one eframe app.
fn main() -> eframe::Result<()> {
eframe::run_native(
"Hello egui",
eframe::NativeOptions::default(),
Box::new(|_cc| Ok(Box::new(MyApp::default()))),
)
}
#[derive(Default)]
struct MyApp {}
impl eframe::App for MyApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
egui::CentralPanel::default().show(ctx, |ui| {
ui.label("Hello from Rust GUI");
});
}
}updateruns every frame - keep work cheap or profile CPU.- egui owns layout each frame - great for tools, less for complex native chrome.
- Cross-platform windowing handled by
winitunder eframe.
Related: egui
2. iced counter button
Elm-style Message enum drives updates.
use iced::widget::{button, column, text, Column};
use iced::Element;
#[derive(Debug, Clone)]
enum Message {
Increment,
}
fn view(count: &i32) -> Element<Message> {
column![
text(format!("Count: {count}")),
button("+").on_press(Message::Increment),
]
.into()
}- Pure
viewfunction maps state to widgets. updateapplies messages immutably - testable reducer pattern.- Styling via iced themes and custom CSS-like rules.
Related: iced
3. Tauri invoke from frontend
Rust command callable from JavaScript UI.
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {name}!")
}- Commands are the IPC boundary - validate all inputs in Rust.
- Keep heavy work off the main thread with async commands when needed.
- Frontend can be React, Svelte, or vanilla - Rust owns security-sensitive APIs.
Related: Tauri
4. Share state with Arc<Mutex<_>> in Tauri
Multiple commands mutating shared app state.
use std::sync::Mutex;
use tauri::State;
struct AppState {
counter: Mutex<i32>,
}
#[tauri::command]
fn increment(state: State<AppState>) -> i32 {
let mut c = state.counter.lock().unwrap();
*c += 1;
*c
}State<T>manages lifetime across invocations.- Prefer
tokio::sync::Mutexin async commands to avoid blocking async runtime. - Document lock ordering if multiple mutexes exist.
Related: State & Architecture
5. Open a native file dialog (concept)
Delegate filesystem picking to OS APIs via plugin or rfd crate.
fn pick_file() -> Option<std::path::PathBuf> {
rfd::FileDialog::new().pick_file()
}- Run dialogs on UI thread per platform requirements.
- Return paths to Rust core for parsing - do not trust paths blindly (symlinks).
- Sandbox Tauri apps need scoped permissions in
tauri.conf.json.
Related: Native Integration
6. Application menu and tray (concept)
Desktop apps need quit, preferences, and background behavior.
- Tauri:
tauri::menuand tray APIs in Rust setup hook. - egui: third-party crates or minimal in-app menu bar.
- iced: window settings and platform-specific integrations vary.
Related: Native Integration
Intermediate Examples
7. egui + persisted settings
Load config on startup, save on change with serde JSON.
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Default)]
struct Settings {
dark_mode: bool,
}- Debounce saves - do not write disk every frame toggle.
- Store in platform app data dir - see native integration page.
- Version settings struct for forward-compatible migrations.
Related: State & Architecture
8. Release build and bundle
Ship .app, .msi, or .deb with icons and metadata.
cargo tauri build
# or cargo install cargo-bundle && cargo bundle- Code signing required for smooth macOS Gatekeeper experience.
- Auto-update needs HTTPS endpoint and signature verification.
- Test on clean VM without dev toolchains installed.
Related: Packaging & Distribution
Stack versions: This page was written for Rust 1.97.0 (edition 2024), Tokio 1.x, Axum 0.8, serde 1.0, sqlx 0.8, clap 4, and Polars 0.46+.