Need help: GUI for a plugin

Hello Rustacians,

I am currently working on a plugin for the Teamspeak Client that is supposed to work at leat on Linux and Windows (Mac OS would be nice but is not a strict requirement). But there are problems with integrating a GUI that I'm struggling to solve.

The plugin and the Teamspeak client communicate through FFI. More specifically, there is a function that is called when the plugin is loaded (let's name the function start_plugin()), a function that is called when the plugin is unloaded/the Teamspeak client is closed (let's name the function stop_plugin()) and there is a function that is called when a button is clicked (let's name it on_button_click(button_id: u32)).

Therefore, the function stop_plugin() should also close every window that belongs to the plugin. Furthermore, the plugin should only spawn a window "on demand", namely only when a button is pressed.
To simplify this, let's say there is a button with the text "open window A" that, when clicked, calls on_button_click(0) which should cause the plugin to spawn/open that window.
Additionally there is a second button with the text "spawn/open window B" that, when clicked, calls on_button_click(1) and causes the plugin to spawn/open that window.

This basically means that there are the following possible scenarios:

  • no plugin window
  • window A is open
  • window B is open
  • window A and window B are open

So far I tried iced and eframe (egui) but I run into similar issues with both because they both use winit under the hood which seems to cause some of the problems.

Besides that, both libraries provide some kind of Application trait which allows you to control what is drawn in the window which I am going to refer to as "GUI Application".

The Problem with the main thread

The GUI libraries both block the main thread until the GUI application is closed. While this makes sense for a standalone application, it is a huge problem for a plugin because this blocks the thread that is used by the Teamspeak client itself, thus "freezing" the client until the plugin's GUI is closed.

No problem, let's just run the GUI on another thread, right? Well... yes, although it does work, winit really does not like that because it not supported on all platforms.

If you just try to start the application inside the closure of thread::spawn, the plugin will panic saying that
"Initializing the event loop outside of the main thread is a significant cross-platform compatibility hazard. If you absolutely need to create an EventLoop on a different thread, you can use the EventLoopBuilderExtUnix::any_thread function."

However if you dig around in the GUI library's source code find the section where the EventLoop is built, you can set EventLoopBuilderExtWayland::with_any_thread(&mut event_loop_builder, true) which forces winit to allow the event loop to be initialized outside the main thread.

However, this leads to the next problem...

The problem with WinitEventLoop(RecreationAttempt)

Winit does not like the event loop to be re-created after the first GUI application instance is closed. In other words: Once the plugin GUI is closed, a new instance of that application cannot be created.
Although, there is an example for egui/eframe that runs multiple windows / applications in serial (i.e. when the first instance of the GUI application is closed, the next instance starts running) (egui/examples/serial_windows at master · emilk/egui · GitHub), this does not seem to work if each application runs on a new thread.
Instead the the thread panics with the error "WinitEventLoop(RecreationAttempt)".

The same error occurs when trying to run iced applications on separate threads in serial.

Ideas to solve these issues

1. Outsourcing the GUI to its own process and communicate over IPC

One solution I can think of is running all the plugin's GUI-related code inside its own child-process.

This could be implemented like this:

  • If there is no GUI-child process and thus no GUI application instance present, create a child process which creates a new instance of the GUI application and tell that GUI application which window to spawn.
  • If there is already a GUI child-process and thus also an instance of the GUI application running, tell that process to let the GUI application instance know that it should open another window.

This ensures the following:

  • There is no more than one GUI child-process running at once.
  • The application always runs on its own main thread which should solve the first problem.
  • The problem with WinitEventLoop(RecreationAttempt) issue cannot occur because each new GUI child process runs independently from each other.

Disadvantages:

  • Windows does not seem provide an easy way fork the current process which means that the GUI code would have to be put in its own separate executable file which would then have to be executed using std::process::Command::spawn.
  • Dealing with inter-process communication. I have never worked with IPC before. If someone knows a good crate for this, please let me know.

2. Run all the GUI logic without a default window

Both iced and eframe spawn a default main window when the GUI application is started. And while, again, this makes perfect sense for a standalone application, it is problematic in a "plugin" situation because the plugin windows are supposed to be spawned on demand.

If it is possible to start the GUI logic without a window present, I could just start the GUI application and the event loop on a separate thread when start_plugin() is called by the Teamspeak client and keep it running until stop_plugin() is called.

I know that winit provides the method Window::set_visible(&self, visible: bool) which can be used to make a window invisible. In theory this could be used to hide the default window. But the documentation states that this is not supported on Wayland (Window in winit::window - Rust). Therefore, I cannot make use of that.

Is there an easy way to stop eframe or iced from spawning that default window that does not involve ripping apart and replacing a lot of the library's code?


Sorry for the long text.
If you have any idea how to solve this, please let me know. I appreciate any help/recommendation. Maybe there are other ideas or libraries that solve these issues.

this consideration is mostly for cross-platform applications due to platform specific quirks, but if portability is not your highest priority, at least for Windows and X11/Wayland (I'm not familiar with other platforms), you can run the event loop on any thread with no problem, you just need to explicitly opt-in this feature, as you already found out, via the builder.with_any_thread(true).

strictly speaking, winit doesn't allow recreation of the event loop, not your GUI application. see e.g. for some related discussion Can't create event loop ondemand · Issue #2431 · rust-windowing/winit · GitHub

I don't think this is technically necessary, but it's the current API design anyway. as far as I know, only web backend support recreation of the event loop.

you can have multiple GUI sessions nevertheless, you don't need multiple event loops. the api you need to use is run_app_on_demand()

you can run a winit::EventLoop without creating any windows before hand. you can create and destroy windows on demand, e.g. using user event.

I'm not familiar with iced, but as far as I know, eframe is intended for simple use cases that just need a quick and easy way to display some GUI widgets, for advanced use cases, you'll need to do a bit more work, e.g. to manually integrate the egui library and backends. it is not very hard to do, if you are familiar with how egui (or immediate mode GUI in general) works.

my personal suggestion is, if you want to use egui but are not comfortable to write your own backend integration (e.g. the integration between egui and winit, opengl/wgpu, etc), run your plugin in a separate process could be a valid solution.

if you are somewhat familiar with winit and egui, you can run the plugin in a thread. here's a sketch for one possible implementation:

/// custom event to send to the event loop running in worker thread
enum PluginEvent {
	/// button 1 clicked
	Button1,
	/// button 2 clicked
	Button2,
	/// quit the event loop
	Shutdown,
}

/// used to send custom events to the event loop
static EVENT_LOOP_PROXY: Mutex<Option<EventLoopProxy<PluginEvent>>> = Mutex::new(None);

/// used to wait the worker thread on shutdown
static PLUGIN_THREAD_HANDLE: Mutex<Option<JoinHandle<()>>> = Mutex::new(None);

/// spawn worker thread
fn plugin_start() {
	let thread = std::thread::spawn(plugin_thread);
	PLUGIN_THREAD_HANDLE.lock().unwrap().replace(thread);
}

/// notify event loop to quit and wait worker thread
fn plugin_stop() {
	if let Some(proxy) = EVENT_LOOP_PROXY.lock().unwrap().as_ref() {
		proxy.send_event(PluginEvent::Shutdown);
	}
	if let Some(thread) = PLUGIN_THREAD_HANDLE.lock().unwrap().take() {
		thread.join().unwrap();
	}
}


/// send custom event to event loop, possibly create GUI window on demand
fn on_button_click(id: i32) {
	if let Some(proxy) = EVENT_LOOP_PROXY.lock().unwrap().as_ref() {
		if id == 1 {
			proxy.send_event(PluginEvent::Button1);
		} else if id == 2 {
			proxy.send_event(PluginEvent::Button2);
		}
	}
}

/// the worker thread: create and run event loop
fn plugin_thread() {
	let mut event_loop = EventLoop::with_user_event().with_any_thread(true).build().unwrap();
	EVENT_LOOP_PROXY.lock().unwrap().replace(event_loop.create_proxy());
	event_loop.run_app_on_demand(&mut MyPlugin::new()).unwrap();}

/// the plugin states
struct MyPlugin {
	window_a: Option<Window>,
	window_b: Option<Window>,
	// other plugin states
	// egui contexts also goes here
}

/// implement event handlers
impl ApplicationHandler<PluginEvent> for MyPlugin {
	/// this is where a typical gui app create their main window, left empty
	fn resumed(&mut self, event_loop: &winit::event_loop::ActiveEventLoop) {
	}

	/// handle our plugin events
	fn user_event(&mut self, event_loop: &winit::event_loop::ActiveEventLoop, event: PluginEvent) {
		match event {
			PluginEvent::Button1 => {
				todo!("create window a if self.window_a is None")
			}
			PluginEvent::Button2 => {
				todo!("create window b if self.window_b is None")
			}
			PluginEvent::Shutdown => {
				event_loop.exit();
			}
		}
	}

	/// this is the callback for window events
	fn window_event(
		&mut self,
		event_loop: &winit::event_loop::ActiveEventLoop,
		window_id: winit::window::WindowId,
		event: winit::event::WindowEvent,
	) {
		if Some(window_id) == self.window_a.as_ref().map(|window| window.id()) {
			// dispatch events for window a
			match event {
				WindowEvent::CloseRequested => {
					// when user closes the window, destroy this window only, not the event loop
					self.window_a = None;
				}
				WindowEvent::RedrawRequested => {
					// draw ui, e.g. using egui, opengl, etc
				}
				// handle other events, e.g. forward events to egui-winit		} else {
			// dispatch events for window b
		}
	}
}
1 Like

Hi, thank you for your answer.
I ended up writing my own backend integration using this (GitHub - kaphula/winit-egui-wgpu-template: Starter template for winit, egui and wgpu project.) template. I modified/added all the necessary parts to make it run on another thread without spawning a default window.
For everyone else seeing this in the future, the template might also be worth looking into if you run into similar problems or want to get up and running quickly with a small custom integration template as a basis.

1 Like

I believe it's MacOS that requires main thread, so if you're on a background thread and need to create a window you should run it with NSObject::performSelector(onMainThread:...).

Pain in the ass in some ways, but the weird input thread attachment logic Windows has is a pain in other ways, so it's probably a wash.

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.