Sworld

Sworld

The only way to do great work is to love what you do.

實現egui窗體隱藏和重新展示

近期在使用 egui 時遇到一個問題,我想要將窗體隱藏起來,再通過托盤圖標喚出,本來以為如此簡單的需求應當非常簡單,結果我發現 egui 官方的框架 eframe 對這個功能的支持並不好,在 GitHub 上長期有著相關討論,例如這個 Issue

本文中,我將首先復現 eframe 的問題,然後介紹一個通過 egui_glowegui_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_glowegui_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,如果成功,我會在這裡更新相關信息。

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。