Skip to main content

Crate egui_async

Crate egui_async 

Source
Expand description

§🔮 egui-async

A simple, batteries-included library for running async tasks across frames in egui and binding their results to your UI.

Supports both native (Tokio) and wasm32 (Web) targets out of the box.

§📖 Overview

Immediate-mode GUI libraries like egui are fantastic, but they pose a significant challenge: how do you run long-running async tasks (like HTTP requests or file I/O) without freezing the UI thread?

egui-async solves this by providing a Bind<T, E> struct. This struct acts as a state machine that bridges the gap between your immediate-mode render loop and your background async runtime.

It handles the lifecycle of the Future, manages the state transitions (IdlePendingFinished), and provides ergonomic UI widgets to visualize that state.

§✨ Features

  • 🔄 Smart State Management: Automatically tracks Idle, Pending, and Finished states. No more manual Option<Result<...>> juggling.
  • 🌐 Universal Support: Seamlessly switches between tokio (native) and wasm-bindgen-futures (web). Write your code once, run everywhere.
  • Lazy Loading: read_or_request allows you to ergonomically trigger async fetches just by trying to read the data in your UI code.
  • ⏱️ Periodic Updates: Built-in support for polling data at specific intervals (e.g., every 10 seconds).
  • 🛑 Task Abortion: Supports physically aborting background tasks on native targets when the UI state changes.
  • 🛠️ Batteries Included: Includes the async runtime for you, along with helper widgets like a refresh button, error popups with retry logic, and more.

§📦 Installation

cargo add egui-async

§🧩 Compatibility

egui APIs change frequently. Ensure you are using a compatible version of egui-async for your project.

egui-asyncegui
>=0.2.00.33
<=0.1.10.32

§🚀 Quick Start

Using egui-async requires two steps: registering the plugin and using a Bind.

§1. Register the Plugin

You must register the EguiAsyncPlugin in your update loop. This drives the frame timers and ensures background tasks can request UI repaints.

impl eframe::App for MyApp {
    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
        // 👇 Crucial: Call this once per frame!
        ctx.plugin_or_default::<egui_async::EguiAsyncPlugin>(); 

        egui::CentralPanel::default().show(ctx, |ui| {
            // Your UI code here...
        });
    }
}

§2. Bind Data

Use Bind<T, E> to manage your data.

use egui_async::Bind;

struct MyApp {
    // Holds a Result<String, String>
    my_ip: Bind<String, String>,
}

// Inside your update loop:
if let Some(res) = self.my_ip.read_or_request(|| async {
    // This async block runs in the background!
    reqwest::get("https://icanhazip.com/")
        .await
        .map_err(|e| e.to_string())?
        .text()
        .await
        .map_err(|e| e.to_string()) // Our Bind<, E> error type is String
}) {
    match res {
        Ok(ip)  => ui.label(format!("IP: {ip}")),
        Err(err) => ui.colored_label(egui::Color32::RED, err),
    }
} else {
    // While the future is running, this block (None) block executes:
    ui.spinner();
}

§💡 Usage Patterns

egui-async is designed to fit several different UI patterns depending on how much control you need.

§Pattern 1: Lazy Loading (“The Getter”)

Scenario: You have data that should load automatically when the user opens a specific tab or window.

Solution: Use read_or_request. If the data isn’t there, it triggers the request and returns None (so you can show a spinner). If it is there, it returns the data.

// If data is missing, start fetching it. 
// If fetching, show a spinner. 
// If finished, show the result.
if let Some(result) = self.data.read_or_request(fetch_data) {
    ui.label(format!("Data: {:?}", result));
} else {
    ui.spinner();
}

§Pattern 2: Explicit State Control (“Full State Machine”)

Scenario: You need a complex UI that looks completely different depending on whether it is loading, failed, or successful (e.g., a login screen).

Solution: Use state_or_request to match exhaustively on every possible state.

use egui_async::StateWithData;

match self.login.state() {
    StateWithData::Idle => {
        // State: Idle -> Show Input Form
        ui.label("Please enter your credentials:");
        ui.add_space(10.0);

        // Use a Grid to align the labels and text boxes nicely
        egui::Grid::new("login_form")
            .num_columns(2)
            .spacing([10.0, 10.0])
            .show(ui, |ui| {
                ui.label("Username:");
                ui.text_edit_singleline(&mut self.username);
                ui.end_row();

                ui.label("Password:");
                ui.add(
                    egui::TextEdit::singleline(&mut self.password).password(true),
                );
                ui.end_row();
            });

        ui.add_space(20.0);

        // TRIGGER: User clicks button -> Transitions to Pending
        if ui.button("Log In").clicked() {
            let fut = perform_login(self.username.clone(), self.password.clone());
            self.login.request(fut);
        }
    }

    StateWithData::Pending => {
        // State: Pending -> Show Loading Indicator
        // We disable inputs or just hide them. Here we show a spinner.
        ui.spinner();
        ui.label("Authenticating...");

        ui.add_space(10.0);

        // Option: Allow cancelling the request
        if ui.button("Cancel").clicked() {
            // On native, this physically aborts the tokio task if configured
            self.login.clear();
        }
    }

    StateWithData::Finished(success_msg) => {
        // State: Finished (Ok) -> Show Success Screen
        ui.label(
            egui::RichText::new("Login Successful!")
                .color(egui::Color32::GREEN)
                .size(20.0),
        );
        ui.label(success_msg);

        ui.add_space(20.0);

        // TRIGGER: Reset to Idle to allow logging in again
        if ui.button("Log Out").clicked() {
            self.username.clear();
            self.password.clear();
            self.login.clear();
        }
    }

    StateWithData::Failed(err_msg) => {
        // State: Failed (Err) -> Show Error and Retry
        ui.label(
            egui::RichText::new("Login Failed")
                .color(egui::Color32::RED)
                .strong(),
        );
        ui.label(err_msg);

        ui.add_space(20.0);

        // TRIGGER: Reset to Idle to try again (keeps previous username/pass typed in)
        if ui.button("Try Again").clicked() {
            self.login.clear();
        }
    }
}

§Pattern 3: The Live Feed (“Periodic Refresh”)

Scenario: You are building a dashboard and need to fetch status updates every 5 seconds.

Solution: Use request_every_sec. It respects the timer and only triggers a new request when the interval has elapsed since the last completion.

// Automatically re-run the future if 5.0 seconds have passed since the last finish.
self.server_status.request_every_sec(check_server_health, 5.0);

// Display the current (cached) data
if let Some(Ok(status)) = self.server_status.read() {
    ui.label(format!("Server Status: {status}"));
}

§Pattern 4: The Power User (“Widgets”)

Scenario: You want a standard “Refresh” button that handles debouncing, loading spinners, and tooltips automatically.

Solution: Use the UiExt trait methods like refresh_button or popup_error.

use egui_async::UiExt; // Import the trait

// Renders a button that spins while Pending.
// Clicking it forces a refresh.
// It also auto-refreshes every 60 seconds.
//
// We use egui's underlying monotonic clock for timing,
// so we aren't making IO time calls every frame! 😉
ui.refresh_button(&mut self.data, fetch_data, 60.0);

// If the bind failed, show a popup window with the error and a "Retry" button.
self.data.read_or_error(fetch_data, ui);

§🧑‍💻 See it in action:

You can find complete, runnable examples for all these patterns in the examples/ directory of the repository:

  • simple.rs – A minimal HTTP fetch example.
  • login.rs – The full “State Machine” pattern with forms and validation.
  • periodic.rs – A dashboard widget that auto-refreshes.
  • advanced.rs - An online, IP locator tool

Look at the code before you run it and try to predict what it does and looks like!

§⚙️ Configuration

§Retain Policy

By default (Bind::default()), egui-async assumes immediate-mode behavior: if you stop calling poll() (or read_*) on a Bind for a frame, it assumes the UI element is no longer visible and drops the data to save memory and reset the state.

If you want to keep data and state even when the UI component is hidden (e.g., inside a collapsed header or a closed tab), set retain to true:

let mut bind = Bind::new(true); // Retain = true

§Native Task Abort

On native targets (non-WASM), you can configure Bind to physically abort the Tokio task when the request is cleared or overwritten. This is useful for cancelling heavy computations or large downloads.

let mut bind = Bind::new(true);
bind.set_abort(true); // Enable physical cancellation (Native only)

⚠️ WebAssembly Note:

Due to browser limitations, set_abort has no effect on WASM targets. The Future will run to completion, but its result will be ignored by the Bind.

There are some methods to do this inside the browser if you desire, but you will need to manually implement it.

§🔧 Under the Hood

How does egui-async bridge the gap between Immediate Mode GUI (60fps loop) and Asynchronous Runtimes?

  1. The Plugin: The EguiAsyncPlugin acts as the heartbeat. It synchronizes a global atomic clock with egui’s input time. This allows Bind instances to measure durations (like “time since finished”) without expensive syscalls or mutex locking on every frame.
  2. The Channel: When you call request(), we spawn a task on the runtime (Tokio or Wasm). We give that task a oneshot::Sender. The Bind struct holds the oneshot::Receiver.
  3. Non-Blocking Polling: On every frame where the UI element is drawn, Bind::poll() checks the receiver.
  • If the channel is empty, it returns Pending (and egui continues drawing).
  • If the channel has data, it moves the state to Finished.
  • Crucially, if the data arrives between frames, Bind automatically requests a repaint from the Context, ensuring your UI updates immediately without user interaction.
§Spawning

On Native targets, tasks are spawned onto the tokio runtime (specifically using tokio::spawn).

On WASM targets, tasks are spawned onto the browser’s event loop using wasm_bindgen_futures::spawn_local. This detection happens automatically at compile time.

§⚠️ Common Issues

1. Forgetting the Plugin If your UI is stuck in Pending forever or your periodic requests aren’t triggering, check your update loop.

fn update(...) {
    // Without this, egui-async has no concept of time!
    ctx.plugin_or_default::<egui_async::EguiAsyncPlugin>(); 
    // ...
}

2. Dropping the Bind The Bind struct owns the receiving end of the async channel. If you create a Bind inside a function scope (local variable) instead of your App struct, it will be dropped at the end of the function, cancelling the specific UI binding.

  • Bad: let mut my_bind = Bind::new(false); inside update().
  • Good: self.my_bind inside struct MyApp.

3. The “Disappearing Data” Mystery By default, Bind uses retain = false. This means if read() or poll() is not called during a specific frame (e.g., the user switched to a different tab in your app), egui-async assumes the data is no longer needed and clears it to free memory.

  • Fix: If you want data to persist while hidden, use Bind::new(true).
§🌍 WASM Configuration

Some crates have features that must be enabled, or must be disabled, under WASM or WASM running in a browser runtime. If you’re seeing nonsensical errors in your browser console, consider removing dependencies until things work, then slowly adding them back to see what breaks the runtime.

A very common example of this issue is the getrandom crate. Be sure to read relevant documentation for how to handle the browser runtime. See the getrandom wasm32 documentation.

§🤝 Contributing

Contributions are more than welcome! If you find a bug or have a feature request, please open an issue. If you want to contribute code, please submit a pull request.

There are many opportunities to create async-native structs on the UiExt trait inside of src/egui.rs that would make for great first-contributions!

§Special thank you to our contributors:

  • @sectore

§⚖️ License

This project is licensed under either of

at your option.

§Note

This is not an official egui product. Please refer to https://github.com/emilk/egui for official crates and recommendations.

Re-exports§

pub use bind::Bind;
pub use bind::State;
pub use bind::StateWithData;
pub use egui::EguiAsyncPlugin;
pub use egui::UiExt;

Modules§

bind
Core state management for asynchronous operations.
egui
egui integration for egui-async.

Macros§

run_once
A macro to run initialization code only once, even in the presence of multiple threads. Returns true if the code was executed in this call, false otherwise.