Add external eventloop support (#6750)

<!--
Please read the "Making a PR" section of
[`CONTRIBUTING.md`](https://github.com/emilk/egui/blob/master/CONTRIBUTING.md)
before opening a Pull Request!

* Keep your PR:s small and focused.
* The PR title is what ends up in the changelog, so make it descriptive!
* If applicable, add a screenshot or gif.
* If it is a non-trivial addition, consider adding a demo for it to
`egui_demo_lib`, or a new example.
* Do NOT open PR:s from your `master` branch, as that makes it hard for
maintainers to test and add commits to your PR.
* Remember to run `cargo fmt` and `cargo clippy`.
* Open the PR as a draft until you have self-reviewed it and run
`./scripts/check.sh`.
* When you have addressed a PR comment, mark it as resolved.

Please be patient! I will review your PR, but my time is limited!
-->

* Closes #2875
* Closes https://github.com/emilk/egui/pull/3340
* [x] I have followed the instructions in the PR template

Adds `create_native`. Similiar to `run_native` but it returns an
`EframeWinitApplication` which is a `winit::ApplicationHandler`. This
can be run on your own event loop. A helper fn `pump_eframe_app` is
provided to pump the event loop and get the control flow state back.

I have been using this approach for a few months.

---------

Co-authored-by: Will Brown <opensource@rebeagle.com>
This commit is contained in:
Will Brown 2025-04-29 06:09:23 -04:00 committed by GitHub
parent fed2ab5df3
commit c075053391
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 614 additions and 22 deletions

View File

@ -1614,6 +1614,26 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "external_eventloop"
version = "0.1.0"
dependencies = [
"eframe",
"env_logger",
"winit",
]
[[package]]
name = "external_eventloop_async"
version = "0.1.0"
dependencies = [
"eframe",
"env_logger",
"log",
"tokio",
"winit",
]
[[package]]
name = "fancy-regex"
version = "0.11.0"
@ -2446,9 +2466,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.161"
version = "0.2.168"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1"
checksum = "5aaeb2981e0606ca11d79718f8bb01164f1d6ed75080182d3abf017e6d244b6d"
[[package]]
name = "libloading"
@ -2603,6 +2623,17 @@ version = "0.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e53debba6bda7a793e5f99b8dacf19e626084f525f7829104ba9898f367d85ff"
[[package]]
name = "mio"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd"
dependencies = [
"libc",
"wasi 0.11.0+wasi-snapshot-preview1",
"windows-sys 0.52.0",
]
[[package]]
name = "multiple_viewports"
version = "0.1.0"
@ -3860,6 +3891,16 @@ dependencies = [
"serde",
]
[[package]]
name = "socket2"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "spirv"
version = "0.3.0+sdk-1.3.268.0"
@ -4178,6 +4219,20 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.44.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6b88822cbe49de4185e3a4cbf8321dd487cf5fe0c5c65695fef6346371e9c48"
dependencies = [
"backtrace",
"libc",
"mio",
"pin-project-lite",
"socket2",
"windows-sys 0.52.0",
]
[[package]]
name = "toml_datetime"
version = "0.6.8"

View File

@ -182,6 +182,14 @@ pub use web::{WebLogger, WebRunner};
#[cfg(any(feature = "glow", feature = "wgpu"))]
mod native;
#[cfg(not(target_arch = "wasm32"))]
#[cfg(any(feature = "glow", feature = "wgpu"))]
pub use native::run::EframeWinitApplication;
#[cfg(not(any(target_arch = "wasm32", target_os = "ios")))]
#[cfg(any(feature = "glow", feature = "wgpu"))]
pub use native::run::EframePumpStatus;
#[cfg(not(target_arch = "wasm32"))]
#[cfg(any(feature = "glow", feature = "wgpu"))]
#[cfg(feature = "persistence")]
@ -242,26 +250,7 @@ pub fn run_native(
mut native_options: NativeOptions,
app_creator: AppCreator<'_>,
) -> Result {
#[cfg(not(feature = "__screenshot"))]
assert!(
std::env::var("EFRAME_SCREENSHOT_TO").is_err(),
"EFRAME_SCREENSHOT_TO found without compiling with the '__screenshot' feature"
);
if native_options.viewport.title.is_none() {
native_options.viewport.title = Some(app_name.to_owned());
}
let renderer = native_options.renderer;
#[cfg(all(feature = "glow", feature = "wgpu"))]
{
match renderer {
Renderer::Glow => "glow",
Renderer::Wgpu => "wgpu",
};
log::info!("Both the glow and wgpu renderers are available. Using {renderer}.");
}
let renderer = init_native(app_name, &mut native_options);
match renderer {
#[cfg(feature = "glow")]
@ -278,6 +267,113 @@ pub fn run_native(
}
}
/// Provides a proxy for your native eframe application to run on your own event loop.
///
/// See `run_native` for details about `app_name`.
///
/// Call from `fn main` like this:
/// ``` no_run
/// use eframe::{egui, UserEvent};
/// use winit::event_loop::{ControlFlow, EventLoop};
///
/// fn main() -> eframe::Result {
/// let native_options = eframe::NativeOptions::default();
/// let eventloop = EventLoop::<UserEvent>::with_user_event().build()?;
/// eventloop.set_control_flow(ControlFlow::Poll);
///
/// let mut winit_app = eframe::create_native(
/// "MyExtApp",
/// native_options,
/// Box::new(|cc| Ok(Box::new(MyEguiApp::new(cc)))),
/// &eventloop,
/// );
///
/// eventloop.run_app(&mut winit_app)?;
///
/// Ok(())
/// }
///
/// #[derive(Default)]
/// struct MyEguiApp {}
///
/// impl MyEguiApp {
/// fn new(cc: &eframe::CreationContext<'_>) -> Self {
/// Self::default()
/// }
/// }
///
/// impl eframe::App for MyEguiApp {
/// fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
/// egui::CentralPanel::default().show(ctx, |ui| {
/// ui.heading("Hello World!");
/// });
/// }
/// }
/// ```
///
/// See the `external_eventloop` example for a more complete example.
#[cfg(not(target_arch = "wasm32"))]
#[cfg(any(feature = "glow", feature = "wgpu"))]
pub fn create_native<'a>(
app_name: &str,
mut native_options: NativeOptions,
app_creator: AppCreator<'a>,
event_loop: &winit::event_loop::EventLoop<UserEvent>,
) -> EframeWinitApplication<'a> {
let renderer = init_native(app_name, &mut native_options);
match renderer {
#[cfg(feature = "glow")]
Renderer::Glow => {
log::debug!("Using the glow renderer");
EframeWinitApplication::new(native::run::create_glow(
app_name,
native_options,
app_creator,
event_loop,
))
}
#[cfg(feature = "wgpu")]
Renderer::Wgpu => {
log::debug!("Using the wgpu renderer");
EframeWinitApplication::new(native::run::create_wgpu(
app_name,
native_options,
app_creator,
event_loop,
))
}
}
}
#[cfg(not(target_arch = "wasm32"))]
#[cfg(any(feature = "glow", feature = "wgpu"))]
fn init_native(app_name: &str, native_options: &mut NativeOptions) -> Renderer {
#[cfg(not(feature = "__screenshot"))]
assert!(
std::env::var("EFRAME_SCREENSHOT_TO").is_err(),
"EFRAME_SCREENSHOT_TO found without compiling with the '__screenshot' feature"
);
if native_options.viewport.title.is_none() {
native_options.viewport.title = Some(app_name.to_owned());
}
let renderer = native_options.renderer;
#[cfg(all(feature = "glow", feature = "wgpu"))]
{
match native_options.renderer {
Renderer::Glow => "glow",
Renderer::Wgpu => "wgpu",
};
log::info!("Both the glow and wgpu renderers are available. Using {renderer}.");
}
renderer
}
// ----------------------------------------------------------------------------
/// The simplest way to get started when writing a native app.

View File

@ -362,6 +362,19 @@ pub fn run_glow(
run_and_exit(event_loop, glow_eframe)
}
#[cfg(feature = "glow")]
pub fn create_glow<'a>(
app_name: &str,
native_options: epi::NativeOptions,
app_creator: epi::AppCreator<'a>,
event_loop: &EventLoop<UserEvent>,
) -> impl ApplicationHandler<UserEvent> + 'a {
use super::glow_integration::GlowWinitApp;
let glow_eframe = GlowWinitApp::new(event_loop, app_name, native_options, app_creator);
WinitAppWrapper::new(glow_eframe, true)
}
// ----------------------------------------------------------------------------
#[cfg(feature = "wgpu")]
@ -386,3 +399,120 @@ pub fn run_wgpu(
let wgpu_eframe = WgpuWinitApp::new(&event_loop, app_name, native_options, app_creator);
run_and_exit(event_loop, wgpu_eframe)
}
#[cfg(feature = "wgpu")]
pub fn create_wgpu<'a>(
app_name: &str,
native_options: epi::NativeOptions,
app_creator: epi::AppCreator<'a>,
event_loop: &EventLoop<UserEvent>,
) -> impl ApplicationHandler<UserEvent> + 'a {
use super::wgpu_integration::WgpuWinitApp;
let wgpu_eframe = WgpuWinitApp::new(event_loop, app_name, native_options, app_creator);
WinitAppWrapper::new(wgpu_eframe, true)
}
// ----------------------------------------------------------------------------
/// A proxy to the eframe application that implements [`ApplicationHandler`].
///
/// This can be run directly on your own [`EventLoop`] by itself or with other
/// windows you manage outside of eframe.
pub struct EframeWinitApplication<'a> {
wrapper: Box<dyn ApplicationHandler<UserEvent> + 'a>,
control_flow: ControlFlow,
}
impl ApplicationHandler<UserEvent> for EframeWinitApplication<'_> {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
self.wrapper.resumed(event_loop);
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
window_id: winit::window::WindowId,
event: winit::event::WindowEvent,
) {
self.wrapper.window_event(event_loop, window_id, event);
}
fn new_events(&mut self, event_loop: &ActiveEventLoop, cause: winit::event::StartCause) {
self.wrapper.new_events(event_loop, cause);
}
fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) {
self.wrapper.user_event(event_loop, event);
}
fn device_event(
&mut self,
event_loop: &ActiveEventLoop,
device_id: winit::event::DeviceId,
event: winit::event::DeviceEvent,
) {
self.wrapper.device_event(event_loop, device_id, event);
}
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
self.wrapper.about_to_wait(event_loop);
self.control_flow = event_loop.control_flow();
}
fn suspended(&mut self, event_loop: &ActiveEventLoop) {
self.wrapper.suspended(event_loop);
}
fn exiting(&mut self, event_loop: &ActiveEventLoop) {
self.wrapper.exiting(event_loop);
}
fn memory_warning(&mut self, event_loop: &ActiveEventLoop) {
self.wrapper.memory_warning(event_loop);
}
}
impl<'a> EframeWinitApplication<'a> {
pub(crate) fn new<T: ApplicationHandler<UserEvent> + 'a>(app: T) -> Self {
Self {
wrapper: Box::new(app),
control_flow: ControlFlow::default(),
}
}
/// Pump the `EventLoop` to check for and dispatch pending events to this application.
///
/// Returns either the exit code for the application or the final state of the [`ControlFlow`]
/// after all events have been dispatched in this iteration.
///
/// This is useful when your [`EventLoop`] is not the main event loop for your application.
/// See the `external_eventloop_async` example.
#[cfg(not(target_os = "ios"))]
pub fn pump_eframe_app(
&mut self,
event_loop: &mut EventLoop<UserEvent>,
timeout: Option<std::time::Duration>,
) -> EframePumpStatus {
use winit::platform::pump_events::{EventLoopExtPumpEvents as _, PumpStatus};
match event_loop.pump_app_events(timeout, self) {
PumpStatus::Continue => EframePumpStatus::Continue(self.control_flow),
PumpStatus::Exit(code) => EframePumpStatus::Exit(code),
}
}
}
/// Either an exit code or a [`ControlFlow`] from the [`ActiveEventLoop`].
///
/// The result of [`EframeWinitApplication::pump_eframe_app`].
#[cfg(not(target_os = "ios"))]
pub enum EframePumpStatus {
/// The final state of the [`ControlFlow`] after all events have been dispatched
///
/// Callers should perform the action that is appropriate for the [`ControlFlow`] value.
Continue(ControlFlow),
/// The exit code for the application
Exit(i32),
}

View File

@ -0,0 +1,25 @@
[package]
name = "external_eventloop"
version = "0.1.0"
authors = ["Will Brown <opensource@rebeagle.com>"]
license = "MIT OR Apache-2.0"
edition = "2021"
rust-version = "1.84"
publish = false
[lints]
workspace = true
[dependencies]
eframe = { workspace = true, features = [
"default",
"__screenshot", # __screenshot is so we can dump a screenshot using EFRAME_SCREENSHOT_TO
] }
env_logger = { version = "0.10", default-features = false, features = [
"auto-color",
"humantime",
] }
winit = { workspace = true }

View File

@ -0,0 +1,7 @@
Example running an eframe application on an external eventloop.
This allows you to run your eframe application alongside other windows and/or toolkits on the same event loop.
```sh
cargo run -p external_eventloop
```

View File

@ -0,0 +1,89 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release
#![allow(rustdoc::missing_crate_level_docs)] // it's an example
use eframe::{egui, UserEvent};
use std::{cell::Cell, rc::Rc};
use winit::event_loop::{ControlFlow, EventLoop};
fn main() -> eframe::Result {
env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`).
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default().with_inner_size([320.0, 240.0]),
..Default::default()
};
let eventloop = EventLoop::<UserEvent>::with_user_event().build().unwrap();
eventloop.set_control_flow(ControlFlow::Poll);
let mut winit_app = eframe::create_native(
"External Eventloop Application",
options,
Box::new(|_| Ok(Box::<MyApp>::default())),
&eventloop,
);
eventloop.run_app(&mut winit_app)?;
Ok(())
}
struct MyApp {
value: Rc<Cell<u32>>,
spin: bool,
blinky: bool,
}
impl Default for MyApp {
fn default() -> Self {
Self {
value: Rc::new(Cell::new(42)),
spin: false,
blinky: false,
}
}
}
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 External Eventloop Application");
ui.horizontal(|ui| {
if ui.button("Increment Now").clicked() {
self.value.set(self.value.get() + 1);
}
});
ui.label(format!("Value: {}", self.value.get()));
if ui.button("Toggle Spinner").clicked() {
self.spin = !self.spin;
}
if ui.button("Toggle Blinky").clicked() {
self.blinky = !self.blinky;
}
if self.spin {
ui.spinner();
}
if self.blinky {
let now = ui.ctx().input(|i| i.time);
let blink = now % 1.0 < 0.5;
egui::Frame::new()
.inner_margin(3)
.corner_radius(5)
.fill(if blink {
egui::Color32::RED
} else {
egui::Color32::TRANSPARENT
})
.show(ui, |ui| {
ui.label("Blinky!");
});
ctx.request_repaint_after_secs((0.5 - (now % 0.5)) as f32);
}
});
}
}

View File

@ -0,0 +1,35 @@
[package]
name = "external_eventloop_async"
version = "0.1.0"
authors = ["Will Brown <opensource@rebeagle.com>"]
license = "MIT OR Apache-2.0"
edition = "2021"
rust-version = "1.84"
publish = false
[lints]
workspace = true
[features]
linux-example = []
[[bin]]
name = "external_eventloop_async"
required-features = ["linux-example"]
[dependencies]
eframe = { workspace = true, features = [
"default",
"__screenshot", # __screenshot is so we can dump a screenshot using EFRAME_SCREENSHOT_TO
] }
env_logger = { version = "0.10", default-features = false, features = [
"auto-color",
"humantime",
] }
log = { workspace = true }
winit = { workspace = true }
tokio = { version = "1", features = ["rt", "time", "net"] }

View File

@ -0,0 +1,10 @@
Example running an eframe application on an external eventloop on top of a tokio executor on Linux.
By running the event loop, eframe, and tokio in the same thread, one can leverage local async tasks.
These tasks can share data with the UI without the need for locks or message passing.
In tokio CPU-bound async tasks can be run with `spawn_blocking` to avoid impacting the UI frame rate.
```sh
cargo run -p external_eventloop_async --features linux-example
```

View File

@ -0,0 +1,130 @@
use eframe::{egui, EframePumpStatus, UserEvent};
use std::{cell::Cell, io, os::fd::AsRawFd as _, rc::Rc, time::Duration};
use tokio::task::LocalSet;
use winit::event_loop::{ControlFlow, EventLoop};
pub fn run() -> io::Result<()> {
env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`).
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default().with_inner_size([320.0, 240.0]),
..Default::default()
};
let mut eventloop = EventLoop::<UserEvent>::with_user_event().build().unwrap();
eventloop.set_control_flow(ControlFlow::Poll);
let mut winit_app = eframe::create_native(
"External Eventloop Application",
options,
Box::new(|_| Ok(Box::<MyApp>::default())),
&eventloop,
);
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let local = LocalSet::new();
local.block_on(&rt, async {
let eventloop_fd = tokio::io::unix::AsyncFd::new(eventloop.as_raw_fd())?;
let mut control_flow = ControlFlow::Poll;
loop {
let mut guard = match control_flow {
ControlFlow::Poll => None,
ControlFlow::Wait => Some(eventloop_fd.readable().await?),
ControlFlow::WaitUntil(deadline) => {
tokio::time::timeout_at(deadline.into(), eventloop_fd.readable())
.await
.ok()
.transpose()?
}
};
match winit_app.pump_eframe_app(&mut eventloop, None) {
EframePumpStatus::Continue(next) => control_flow = next,
EframePumpStatus::Exit(code) => {
log::info!("exit code: {code}");
break;
}
}
if let Some(mut guard) = guard.take() {
guard.clear_ready();
}
}
Ok::<_, io::Error>(())
})
}
struct MyApp {
value: Rc<Cell<u32>>,
spin: bool,
blinky: bool,
}
impl Default for MyApp {
fn default() -> Self {
Self {
value: Rc::new(Cell::new(42)),
spin: false,
blinky: false,
}
}
}
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 External Eventloop Application");
ui.horizontal(|ui| {
if ui.button("Increment Now").clicked() {
self.value.set(self.value.get() + 1);
}
if ui.button("Increment Later").clicked() {
let value = self.value.clone();
let ctx = ctx.clone();
tokio::task::spawn_local(async move {
tokio::time::sleep(Duration::from_secs(1)).await;
value.set(value.get() + 1);
ctx.request_repaint();
});
}
});
ui.label(format!("Value: {}", self.value.get()));
if ui.button("Toggle Spinner").clicked() {
self.spin = !self.spin;
}
if ui.button("Toggle Blinky").clicked() {
self.blinky = !self.blinky;
}
if self.spin {
ui.spinner();
}
if self.blinky {
let now = ui.ctx().input(|i| i.time);
let blink = now % 1.0 < 0.5;
egui::Frame::new()
.inner_margin(3)
.corner_radius(5)
.fill(if blink {
egui::Color32::RED
} else {
egui::Color32::TRANSPARENT
})
.show(ui, |ui| {
ui.label("Blinky!");
});
ctx.request_repaint_after_secs((0.5 - (now % 0.5)) as f32);
}
});
}
}

View File

@ -0,0 +1,15 @@
#![allow(rustdoc::missing_crate_level_docs)] // it's an example
#[cfg(target_os = "linux")]
mod app;
#[cfg(target_os = "linux")]
fn main() -> std::io::Result<()> {
app::run()
}
// Do not check `app` on unsupported platforms when check "--all-features" is used in CI.
#[cfg(not(target_os = "linux"))]
fn main() {
println!("This example only supports Linux.");
}