egui-async 0.2.1

A simple library for running async tasks in egui and binding their results to your UI.
Documentation

egui-async

Crates.io Docs.rs License

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

Supports both native and wasm32 targets.

if let Some(res) = self.data_bind.read_or_request(|| async {
    reqwest::get("https://icanhazip.com/")
        .await
        .map_err(|e| e.to_string())?
        .text()
        .await
        .map_err(|e| e.to_string())
}) {
    match res {
        Ok(ip) => {
            ui.label(format!("Your public IP is: {ip}"));
        }
        Err(err) => {
            ui.colored_label(
                egui::Color32::RED,
                format!("Could not fetch IP.\nError: {err}"),
            );
        }
    }
} else {
    ui.label("Getting public IP...");
    ui.spinner();
}

What is this?

Immediate-mode GUI libraries like egui are fantastic, but they pose a challenge: how do you run a long-running or async task (like a network request), between frames, without blocking the UI thread?

egui-async provides a simple Bind<T, E> struct that wraps an async task, manages its state (Idle, Pending, Finished), and provides ergonomic helpers to render the UI based on that state.

It works with both tokio on native and wasm-bindgen-futures on the web, right out of the box.

Features

  • Simple State Management: Wraps any Future and tracks its state.
  • WASM Support: Works seamlessly on both native and wasm32 targets.
  • Ergonomic Helpers: Methods like read_or_request_or_error simplify UI logic into a single line.
  • Convenient Widgets: Includes a refresh_button and helpers for error popups.
  • Minimal Dependencies: Built on tokio and (for wasm) wasm-bindgen-futures.

How it Works

egui-async works by bridging egui's immediate-mode rendering loop with a background async runtime.

  1. Plugin Registration: You must register the EguiAsyncPlugin with egui. The easiest way is to call ctx.plugin_or_default::<egui_async::EguiAsyncPlugin>(); once per frame. This plugin updates a global frame timer used by all Bind instances.
  2. Bind::request(): When you start an operation, it spawns a Future onto a runtime (tokio on native, wasm-bindgen-futures on web).
  3. Communication: The spawned task is given a tokio::sync::oneshot::Sender. When the future completes, it sends the Result back to the Bind instance, which holds the Receiver.
  4. Polling: On each frame, Bind checks its receiver to see if the result has arrived. If it has, Bind transitions from the Pending state to the Finished state.
  5. UI Update: Your UI code can then check the Bind's state and display the data, an error, or a loading indicator.

Quickstart

Here is a minimal example using eframe that shows how to fetch data from an async function.

First, add egui-async to your dependencies:

cargo add egui-async

Then, use the Bind struct in your application:

use eframe::egui;
use egui_async::{Bind, EguiAsyncPlugin};

struct MyApp {
    /// The Bind struct holds the state of our async operation.
    data_bind: Bind<String, String>,
}

impl Default for MyApp {
    fn default() -> Self {
        Self {
            // We initialize the Bind and tell it to not retain data
            // if it's not visible for a frame.
            // If set to true, this will retain data even as the
            // element goes undrawn.
            data_bind: Bind::new(false), // Same as Bind::default()
        }
    }
}

impl eframe::App for MyApp {
    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
        // This registers the plugin that drives the async event loop.
        // It's idempotent and cheap to call on every frame.
        ctx.plugin_or_default::<EguiAsyncPlugin>(); // <-- REQUIRED

        egui::CentralPanel::default().show(ctx, |ui| {
            ui.heading("Async Data Demo");
            ui.add_space(10.0);

            // Request if `data_bind` is None and idle
            // Otherwise, just read it
            if let Some(res) = self.data_bind.read_or_request(|| async {
                reqwest::get("https://icanhazip.com/")
                    .await
                    .map_err(|e| e.to_string())?
                    .text()
                    .await
                    .map_err(|e| e.to_string())
            }) {
                match res {
                    Ok(ip) => {
                        ui.label(format!("Your public IP is: {ip}"));
                    }
                    Err(err) => {
                        ui.colored_label(
                            egui::Color32::RED,
                            format!("Could not fetch IP.\nError: {err}"),
                        );
                    }
                }
            } else {
                ui.label("Getting public IP...");
                ui.spinner();
            }
        });
    }
}

// Boilerplate
fn main() {
    let native_options = eframe::NativeOptions::default();
    eframe::run_native(
        "egui-async example",
        native_options,
        Box::new(|_cc| Ok(Box::new(MyApp::default()))),
    )
    .unwrap();
}

Common API Patterns

egui-async offers several helper methods on Bind to handle common UI scenarios. Here are the most frequently used patterns.

The Full State Machine: state_or_request

This is the most powerful and explicit pattern. Use it when you want to render a different UI for every possible state: Pending, Finished with data, Failed with an error, or Idle. It's perfect for detailed components that need to show loading spinners, error messages, and the final data.

use egui_async::StateWithData;

match self.data_bind.state_or_request(my_async_fn) {
    StateWithData::Idle => { /* This is usually skipped */ }
    StateWithData::Pending => { ui.spinner(); }
    StateWithData::Finished(data) => { ui.label(format!("Success: {data}")); }
    StateWithData::Failed(err) => { ui.colored_label(egui::Color32::RED, err.to_string()); }
}

Simple Data Display: read_or_request

Use this pattern when you primarily care about the successful result and want a simple loading state. It returns an Option<&Result<T, E>>. If the value is Some, you can handle the Ok and Err cases. If it's None, the request is Pending, so you can show a spinner.

if let Some(result) = self.data_bind.read_or_request(my_async_fn) {
    match result {
        Ok(data) => { ui.label(format!("Your IP is: {data}")); }
        Err(err) => { ui.colored_label(egui::Color32::RED, err.to_string()); }
    }
} else {
    ui.spinner();
    ui.label("Loading...");
}

Periodic Refresh: request_every_sec

Use this for data that should be updated automatically on a timer, like a dashboard widget. You provide an interval in seconds, and egui-async will trigger a new request when the interval has passed since the last successful completion.

// In your update loop:
let refresh_interval_secs = 20.0;
self.live_data.request_every_sec(fetch_live_data, refresh_interval_secs);

// You can still read the data to display it
if let Some(Ok(data)) = self.live_data.read() {
    ui.label(format!("Live data: {data}"));
}

License

This project is licensed under either of

at your option.

Contribution

Contributions are welcome! Please feel free to submit a pull request or open an issue.

Todo

In the future I may consider a registry architecture rather than polling on each request, which would allow mature threading-- however this poses unique difficulties of its own. Feel free to take a shot at it in a PR.

A builder API is a likely "want" for 1.0.

Notes

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