0x53A

Running egui applications on the web has been possible for some time. The official template uses trunk to manage all that. The app will render into a fullscreen canvas, taking over the whole page.

It works fine, I've built a few small applications using that pattern. But, it wasn't clear to me how I would integrate a smaller egui app into a larger (html) page. Besides, trunk uses a template html file, and it feels like a little bit too much magic to me.


Now we take the egui, and put it into the component

So, instead, let's properly seperate concerns. We'll compile the egui app into a web component, after which, rust is done. It no longer cares about how it is embedded. We can then write an extremely simple html page and load that web component.

I believe this is easier done than said, so please, go here: https://0x53a.github.io/web-component-rs/.
(The source code is available here: https://github.com/0x53a/web-component-rs).

Because these are just web components, it's trivial to embed third party components:

Expand Me!

Please press F12 to look at the source code.

All that's needed is:


<egui-plotter style="display: block; height: 200px;" />
<script type="module">
    import init from 'https://0x53a.github.io/web-component-rs/fourth_component/pkg/fourth_component.js';
    await init();
</script>
        

Now, let's move on to how it internally works.

First, a refresher on how custom web components work:

You only need to define a JS class that derives from HTMLElement, and register it using customElements.define. Please see the mozilla article for more details: https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements#custom_element_lifecycle_callbacks

In rust, we define a struct which implements a trait and use the WebComponent derive macro to generate the boilerplate.


#[derive(WebComponent)]
#[web_component(name = "my-counter", observed_attributes = ["value"])]
pub struct MyCounter {
    // ...
    element: Option<web_sys::HtmlElement>,
}

impl WebComponent for MyCounter {
    fn attach(&mut self, element: &web_sys::HtmlElement) {
        self.element = Some(element.clone());
    }

    fn connected(&mut self) {
        // ...
    }
}
        

The derive macro creates a function, which will dynamically eval a JS class declaration and register the element.


pub fn setup_mycounter() {

    // ...

    let class_definition = format!(
        r#"
        class {js_class} extends HTMLElement {{
            constructor() {{
                super();
                window.__wasm_attach_{js_class}(this);
            }}

            connectedCallback() {{
                window.__wasm_connected_{js_class}(this);
            }}

            disconnectedCallback() {{
                window.__wasm_disconnected_{js_class}(this);
            }}

            adoptedCallback() {{
                window.__wasm_adopted_{js_class}(this);
            }}

            attributeChangedCallback(name, oldValue, newValue) {{
                window.__wasm_attribute_changed_{js_class}(this, name, oldValue, newValue);
            }}

            static get observedAttributes() {{
                return {observed_attrs};
            }}
        }}

        customElements.define('{tag_name}', {js_class});
        "#, ...
    );

    eval(class_definition);
}
        

The above code is generated, we just need to make sure to call it


#[wasm_bindgen(start)]
pub fn start() {
    simple::setup_mycounter();
}
        

#[wasm_bindgen(start)] is more or less comparable to dllmain, it's called when the wasm module is loaded.

So, with that, all we then need to do is initialise eframe inside the connected callback.


impl WebComponent for MyCounter {
    fn connected(&mut self) {
        let element = self.element.as_ref().unwrap();

        let shadow = element
            .attach_shadow(&web_sys::ShadowRootInit::new(web_sys::ShadowRootMode::Open))
            .expect("failed to attach shadow root");

        let document = web_sys::window().unwrap().document().unwrap();

        let canvas = document
            .create_element("canvas")
            .expect("failed to create canvas")
            .unchecked_into::<HtmlCanvasElement>();

        let canvas_style = canvas.style();
        canvas_style.set_property("display", "block").unwrap();
        canvas_style.set_property("width", "100%").unwrap();
        canvas_style.set_property("height", "100%").unwrap();

        shadow.append_child(&canvas).unwrap();

        let runner = eframe::WebRunner::new();
        let element_copy = element.clone();

        wasm_bindgen_futures::spawn_local(async move {
            let result = runner
                .start(
                    canvas,
                    eframe::WebOptions::default(),
                    Box::new(|cc| Ok(Box::new(EguiApp::new(cc)))),
                )
                .await;

            if let Err(e) = &result {
                web_sys::console::error_1(e);
            }

            if result.is_ok() {
                EguiComponent::with_element(&element_copy, |comp| {
                    comp.runner = Some(runner);
                });
            }
        });
    }
}
    

So yeah, that's it. I've change one of my applications to the new pattern, you can see the commit here: https://github.com/0x53A/UAS-SigVer/commit/833a8052f24cff59bb90f14e59077fbf21c90c4f
That commit moves some things around so it looks bigger than it is. As an example, with trunk, your rust wasm project is an application with a main function. With web components and wasm-pack, it's a library.