近期在使用 egui
時遇到一個問題,我想要將窗體隱藏起來,再通過托盤圖標喚出,本來以為如此簡單的需求應當非常簡單,結果我發現 egui
官方的框架 eframe
對這個功能的支持並不好,在 GitHub 上長期有著相關討論,例如這個 Issue。
本文中,我將首先復現 eframe
的問題,然後介紹一個通過 egui_glow
與 egui_winit
實現的解決方案,提供一個尚且能用的示例。
復現問題#
前置依賴#
以下是我的 Cargo.toml
文件:
[package]
name = "test_egui"
version = "0.1.0"
edition = "2024"
[dependencies]
egui = "0.31.1"
eframe = "0.31.1"
tokio = { version = "1.44.1", features = ["full"] }
問題復現#
為了簡化問題,我將上述問題抽象成這樣一個模型:編寫一個 rust 程序,後台線程控制它定時出現或消失。
為了實現這個功能,我們很容易就會寫出這樣的代碼:
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.horizontal(|ui| {
ui.label("Hello, world!");
});
});
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let rt = tokio::runtime::Runtime::new()?;
let _guard = rt.enter();
eframe::run_native(
"Egui Test App",
Default::default(),
Box::new(|_cc| {
let ctx = _cc.egui_ctx.clone();
rt.spawn(async move {
loop {
println!("Running background task...");
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false));
ctx.request_repaint();
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true));
ctx.request_repaint();
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
}
});
Ok(Box::new(MyApp {}))
}),
)?;
Ok(())
}
然而很遺憾,這個代碼並不能正常工作。我們可以看到,窗體在後台線程中是可以被隱藏的,但是調用 ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true));
卻沒有任何效果,通過打印出來的日誌可以看出,循環是正常執行的,但是窗體並沒有重新展示出來。
這是因為 eframe
在窗體隱藏時不會去處理這些事件,從 GitHub 的相關討論來看,目前這是一個沒有被解決的問題,因此,我們需要暫時不使用 eframe
,而是使用 egui
的底層庫 egui_glow
和 egui_winit
來實現這個功能。
解決方案#
如果我們能夠自己處理 winit
的事件循環,那麼就可以輕鬆定制有關窗體的行為了,我找到了官方的一個示例:Prue-glow。
更新依賴#
既然我們不再使用 eframe
了,那麼我們需要參照上方鏈接把 Cargo.toml
中的依賴更新為:
[dependencies]
egui = "0.31.1"
egui-winit = "0.31.1"
winit = "0.30.9"
glow = "0.16.0"
egui_glow = {version = "0.31.1", features = ["winit"]}
glutin = "0.32.2"
glutin-winit = "0.5.0"
tokio = { version = "1.44.1", features = ["full"] }
log = "0.4.26"
改造後的main
#
fn main() -> Result<(), Box<dyn std::error::Error>> {
let event_loop = winit::event_loop::EventLoop::<event::UserEvent>::with_user_event()
.build()
.unwrap();
let proxy = event_loop.create_proxy();
let rt = tokio::runtime::Runtime::new()?;
let _guard = rt.enter();
let proxy_clone = proxy.clone(); // !NOTICE: clone the proxy for the background task
rt.spawn(async move {
loop {
println!("Running background task...");
proxy_clone
.send_event(event::UserEvent::HideWindow)
.unwrap();
proxy_clone
.send_event(event::UserEvent::Redraw(Duration::ZERO))
.unwrap();
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
proxy_clone
.send_event(event::UserEvent::ShowWindow)
.unwrap();
proxy_clone
.send_event(event::UserEvent::Redraw(Duration::ZERO))
.unwrap();
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
}
});
let mut app = app::GlowApp::new(
proxy,
Box::new(|egui_ctx| {
egui::CentralPanel::default().show(egui_ctx, |ui| {
ui.heading("Hello World!");
});
}),
);
event_loop.run_app(&mut app).expect("failed to run app");
Ok(())
}
這是我改造之後的main
函數,主要的變化在於我們使用 winit
的事件循環來處理窗體的行為。我們在後台線程通過 EventLoopProxy<UserEvent>
中發送 UserEvent
來控制窗體的顯示和隱藏。
事件處理#
同理,我們需要實現 ApplicationHandler<UserEvent>
,這裡才是接受並處理 UserEvent
的地方。如果你需要管理應用的一些狀態,可以直接在 GlowApp
結構體中添加相關字段(如我註釋掉的 AppState
),並且在 user_event
中處理與更新,這裡我只建議進行簡單的數據顯示等操作,計算的部分還是放在後台比較好,否則會影響到 UI 的流暢度。另外,你也可能會需要更新 update_ui
以接受 AppState
。
pub struct GlowApp {
proxy: winit::event_loop::EventLoopProxy<UserEvent>,
gl_window: Option<GlutinWindowContext>,
gl: Option<Arc<glow::Context>>,
egui_glow: Option<egui_glow::EguiGlow>,
repaint_delay: std::time::Duration,
clear_color: [f32; 3],
window_hidden: bool,
update_ui: Box<dyn Fn(&egui::Context) + Send + Sync + 'static>,
// state: AppState,
}
impl winit::application::ApplicationHandler<UserEvent> for GlowApp {
// ... skip other methods
fn user_event(&mut self, _event_loop: &winit::event_loop::ActiveEventLoop, event: UserEvent) {
match event {
UserEvent::Redraw(delay) => self.repaint_delay = delay,
UserEvent::ShowWindow => {
self.window_hidden = false;
if let Some(ref gl_window) = self.gl_window {
gl_window.window().set_visible(true);
gl_window.window().request_redraw();
}
}
UserEvent::HideWindow => {
self.window_hidden = true;
if let Some(ref gl_window) = self.gl_window {
gl_window.window().set_visible(false);
}
}
}
}
}
總結#
至此,程序窗體就會定時隱藏和重新展示了,若想要實現角標的事件處理功能,可以參考這個示例來把 tray 事件添加到我們的事件中,再在 user_event
中處理。
完整代碼#
為什麼我不在上方直接呈現出所有代碼呢?當然是因為太長了…… 我認為對於實現簡單的需求來說,了解上面的部分就可以很快地上手修改了,把我的(或者官方的)案例複製走即可。當然,我這份代碼有許多小問題,不適合直接用於生產環境,請仔細檢查後再使用。
另外,我也正在嘗試構建一個 egui 桌面應用的模版,歡迎關注我的 GitHub 主頁:mcthesw,如果成功,我會在這裡更新相關信息。