Kan jeg lage et native GUI i nettleseren?
I bakgrunnen til mitt forrige blogginnlegg om caching nevnte jeg at jeg jobba med en eksperimentell, alternativ frontend til intern-appen vår Mittari. Som jeg skrev der, så har jeg tidligere brukt egui i Rust til å raskt utvikle GUI-er som løser utvikler-retta oppgaver. Dette var native-applikasjoner som kjørte på maskina til utviklerne. Da jeg holdt på med dette var det også tydelig at det var mulig å servere egui i nettleseren. Dette gjøres ved å kompilere til WebAssembly (WASM), som jeg har vært interessert i å prøve.
I dette innlegget skal jeg vise hvordan man kan
- bygge et GUI i Rust med egui
- bruke trunk til å kompilere GUI-et til WASM og produsere HTML + JS + WASM man kan servere som en app
Om du ikke har hørt om WASM før, så er det en av måtene man kan kjøre kode i nettleseren på. Ved siden av HTML og CSS, så er det tradisjonelt kun JavaScript som har fått lov å kjøre i nettleseren. WASM er støtta i alle de store nettleserne fra år 2017, og fra da var det mulig å skrive klient-kode i alle programmeringsspråk kan kompileres til WASM. Dette inkluderer C, C++, Rust, Go, .NET-språk, med flere.
egui
egui er en implementasjon av immediate mode GUI for Rust. Immediate mode er i kontrast til retained mode ved at elementene som defineres ikke finnes før koden som benytter seg av dem kjøres. Der man i retained mode definerer alle elementer ett sted, og interagerer med dem fra forskjellige steder rundt i applikasjonskoden, må man i immediate mode lage elementer underveis. Dette er kanskje enklere å demonstrere med et eksempel.
Her er koden for applikasjonen demonstrert i bildet og videoen over:
/// Kalles hver gang GUI-et skal tegnes
fn update(&mut self, ctx: &egui::Context, _: &mut eframe::Frame) {
egui::CentralPanel::default().show(ctx, |ui| {
ui.heading("My egui Application");
ui.horizontal(|ui| {
ui.label("Your name: ");
ui.text_edit_singleline(&mut self.name);
});
ui.add(egui::Slider::new(&mut self.age, 0..=120).text("age"));
// På dette punktet i kjøringa av koden finnes ikke Increment-knappen.
if ui.button("Increment").clicked() {
// Nå som ui.button() er kalt så finnes knappen, og den er tegnet.
// I tillegg vet vi at knappen ble trykket på, og vi kan utføre
// den tilknyttede operasjonen.
self.age += 1;
}
if self.name.is_empty() {
ui.label("Please enter your name");
}
else {
ui.label(format!("Hello '{}', age {}", self.name, self.age));
}
});
}
Denne modellen gjør at koden som påvirker eller leser tilstanden til elementene i GUI-et er meget nær der elementene defineres. Dette gjør igjen at applikasjonen din har mindre global tilstand som må håndteres og synkroniseres.
I og med at elementer ikke finnes før de kalles for hver iterasjon av tegne-loopen, gjør at elementer som defineres tidlig ikke kan vite om elementer som defineres seinere. Dette skaper en stor utfordring når det gjelder layout. F.eks., om man skal tegne en tabell, kan man ikke vite hvor bred man skal tegne en kolonne om man definerer alle elementene i kolonna etter kolonna blir definert. Da må man i mange tilfeller sette bredden på forhånd, og passe på at ingen av elementene er større enn dette. Det gjør at immediate mode ofte ikke brukes i forbruker-retta applikasjoner hvor design og estetikk er en stor del av produktet. Immediate mode er derimot veldig populært i spill eller «in-house» verktøy.
Få det på nett
Så, hvordan kan vi kjøre dette i nettleseren? Det som er fint med egui er at rammeverket i bunn og grunn bare produserer et sett av triangler med tekstur, og man kan derfor i teorien bruke egui overalt man kan tegne dette. Nettlesere vet hvordan man gjør dette, ved hjelp av f.eks. WebGL eller WebGPU, og det gjør det fullt mulig å få lik opplevelse som i skrivebordsapplikasjonen.
Standardrammeverket til egui, eframe, støtter wasm32-unknown-unknown
, som er en target triplet som beskriver nettopp WASM, det binære formatet nettlesere støtter.
Dette gjør at man kan skrive cargo build --target wasm32-unknown-unknown
for å bygge en my_app.wasm
fil, og denne kan man benytte seg av fra JavaScript, f.eks. slik:
WebAssembly.instantiateStreaming(fetch("my_app.wasm"), importObject).then(
(obj) => {
obj.instance.exports.my_function();
},
);
Dersom WASM-koden gjør noe fancy, som f.eks. å allokere minne eller bruke funksjonalitet som krever at man kaller tilbake til JavaScript, må man gjerne sette opp en del for at WASM-koden skal fungere. Det er en del verktøy som hjelper til med å gjøre denne jobben:
wasm-bindgen
genererer bindinger som gjør at man enkelt kan kalle JavaScript-funksjoner fra Rust og vice versa. Prosjektet inkluderer også:js-sys
som gir tilgang til funksjoner som man får fra JavaScript-standardenweb-sys
som gir tilgang til funksjoner som man får via nettleseren
wasm-pack
er et verktøy som gjør at du enkelt kan bunte sammen WASM og JavaScript hjelpekode til NPM-pakker eller for å enkelt servere dette på nett
I tillegg har vi det som heter trunk som gjør det til en enkel prosess å lage apper fra Rust-prosjekter, og lager konfigurasjoner for wasm-bindgen
og for wasm-pack
.
Man kan også hvordan appen ser ut i lokal utvikling ved å kjøre trunk serve
.
trunk build
vil generere filer i en dist/
-katalog som man kan servere med en helt vanlig web-server.
For å slippe å sette opp konfigurasjonen til noen av verktøyene over, finnes eframe template, som er et fint utgangspunkt for å komme i gang med et prosjekt som bygger et egui GUI som kjører både native og i browser.