Zum Inhalt springen

GUI-Apps in Rust: Tauri, egui, Slint und Iced

Früher oder später willst du ein echtes Fenster mit Buttons, nicht nur Text im Terminal. Geht das mit Rust? Ja. GUI-Apps in Rust sind schnell, klein und sicher, und es gibt heute mehrere gute Toolkits. Die GUI-Welt von Rust ist aber jünger als etwa C# mit WPF oder JavaScript mit Electron. Deshalb hilft es, dir erst einen Überblick zu verschaffen, bevor du dich für eines entscheidest.

Auf dieser Seite bekommst du einen ehrlichen Überblick, eine Tabelle als Entscheidungshilfe, die Idee hinter Immediate Mode und Retained Mode und ein erstes kleines Programm für die beliebtesten Toolkits.

Wo kannst du den Code ausführen? Eine GUI braucht ein echtes Fenster. Die Programme für egui, Tauri, Slint und Iced sind deshalb für deinen eigenen Rechner gedacht, mit cargo run. Die egui- und Iced-Programme auf dieser Seite wurden mit cargo check gegen die aktuellen Versionen kompiliert. Zwei Abschnitte bilden eine GUI in reinem Rust nach, das Text ausgibt, statt ein Fenster zu zeichnen. Die laufen im Browser – achte auf die Buttons „Selbst ausprobieren“.

Die Community-Seite Are we GUI yet? (auf Englisch) fasst es in einem Satz zusammen: „The roots aren’t deep but the seeds are planted.“ – die Wurzeln sind noch nicht tief, aber die Saat ist ausgebracht. Das trifft es immer noch:

  • Richtig gut: Geschwindigkeit, kleine Apps und die Sicherheit von Rust. egui, Iced und Slint zeichnen alles selbst, also läuft eine Codebasis unter Windows, macOS und Linux. Mit Tauri kannst du deine Web-Kenntnisse weiterverwenden. Die App bleibt dabei viel kleiner als mit Electron, weil Tauri die Webview nutzt, die schon auf dem System installiert ist.
  • Wächst noch: Es gibt kein einzelnes „Standard“-Toolkit wie Qt in C++ oder WPF in C#. Mehrere beliebte Crates sind noch bei Version 0.x, Updates können deinen Code also kaputt machen. Barrierefreiheit, Text von rechts nach links und ein nativer Look unterscheiden sich von Toolkit zu Toolkit.
  • Die gute Nachricht: Du musst nicht das perfekte Toolkit finden. Für eine erste App funktioniert jedes Toolkit aus der Tabelle unten gut.

Einen ausführlicheren Vergleich findest du im Überblick über Rust-GUI-Bibliotheken von 2025 (auf Englisch), der viele Toolkits einzeln testet.

Echte Apps, die du dir heute ansehen kannst:

  • Der Rerun Viewer, ein Werkzeug zum Betrachten von Daten aus Robotern und Computer Vision, ist mit egui gebaut. (Rerun finanziert auch die Entwicklung von egui.)
  • COSMIC, die Desktop-Umgebung von System76 für Linux, ist mit Iced gebaut. Genauso der IRC-Chat-Client Halloy.
  • Desktop-Werkzeuge mit einer Oberfläche im Web-Stil: Notiz-Apps, Dashboards und kleine Business-Tools passen oft gut zu Tauri.
  • Bildschirme auf Geräten: Slint läuft auch auf Mikrocontrollern und eignet sich deshalb gut für Touchscreens an Maschinen und Haushaltsgeräten.

Die Versionen sind die neuesten Releases im Oktober 2026.

Crate Version Wie du die UI schreibst Läuft auf Nimm es, wenn …
Tauri 2.12 (3.0 im Alpha-Stadium) HTML, CSS und JavaScript für die Oberfläche, Rust für die Logik Windows, macOS, Linux, Android, iOS du dich mit Webentwicklung auskennst oder eine reichhaltige, gestaltete Oberfläche willst
egui + eframe 0.37 Reines Rust, Immediate Mode Windows, macOS, Linux, Web (Wasm) du Werkzeuge, Debug-Panels oder Editoren baust und in reinem Rust am schnellsten loslegen willst
Slint 1.18 Eigene .slint-Auszeichnungssprache, dazu Rust für die Logik Desktop, Embedded, Mobil, Web du eine polierte App oder einen Bildschirm auf einem Embedded-Gerät baust
Iced 0.14 Reines Rust, Elm-Architektur (State, Messages, update, view) Windows, macOS, Linux, Web du sauberen, vorhersehbaren Code magst, bei dem alle Änderungen an einer Stelle passieren
gtk-rs (gtk4) 0.11 Rust-Bindings für GTK 4, Retained Mode Vor allem Linux; Windows und macOS mit mehr Einrichtung du nativ aussehende Apps für den GNOME-Desktop unter Linux willst
Dioxus 0.7 Komponenten wie in React, mit einem rsx!-Makro Web, Desktop (Webview), Mobil du React kennst und eine Codebasis für Web und Desktop willst

Ein paar ehrliche Hinweise zu Lizenzen und Reife:

  • Slint ist kostenlos unter der GPLv3. Für Closed-Source-Apps auf Desktop, Mobil und Web ist es unter seiner lizenzgebührenfreien Lizenz ebenfalls kostenlos (du musst zeigen, dass du Slint verwendest). Closed-Source-Produkte im Embedded-Bereich brauchen eine kostenpflichtige Lizenz. Lies slint.dev/pricing (auf Englisch), bevor du etwas auslieferst.
  • egui, Iced und Dioxus sind noch bei 0.x. Rechne bei jedem Release mit kleinen Breaking Changes und prüf, ob ein Tutorial zu deiner Version passt. Ältere egui-Tutorials verwenden zum Beispiel fn update(&mut self, ctx: &egui::Context, ...), eframe 0.37 dagegen fn ui(&mut self, ui: &mut egui::Ui, ...).
  • Tauri, Dioxus Desktop und gtk-rs hängen von Systembibliotheken ab. Mehr dazu unten unter Plattform-Voraussetzungen.

Bei einer großen Frage sind sich die Toolkits nicht einig: Wer merkt sich die Widgets?

Im Retained Mode (gtk-rs, Slint, die Webseite in Tauri und die meisten Toolkits in anderen Sprachen) erzeugst du einen Button einmal. Das Toolkit hebt ihn in einem Baum aus Widgets auf. Du hängst einen Callback an, und wenn jemand klickt, ruft das Toolkit deinen Callback auf. Um den Text des Buttons später zu ändern, brauchst du einen Handle auf genau diesen Button.

Im Immediate Mode (egui) wird nichts aufgehoben. Deine UI-Funktion läuft in jedem Frame neu und beschreibt einfach, was jetzt gerade zu sehen sein soll. Ein Button wird in einer Zeile gezeichnet und abgefragt: if ui.button("+").clicked() { ... }. Es gibt keine Callbacks und keine Handles. Der Zustand deiner App lebt in deinem eigenen Struct, und die UI wird immer daraus gezeichnet. So kann der Bildschirm nie aus dem Takt mit deinen Daten geraten.

Der Preis: Das Toolkit berechnet in jedem Frame das Layout der ganzen UI. Eine riesige UI kostet deshalb mehr Rechenzeit, und manche Layouts (etwa ein Fenster zu zentrieren, dessen Größe noch nicht bekannt ist) sind schwieriger. egui zeichnet nur dann einen neuen Frame, wenn etwas passiert, zum Beispiel eine Mausbewegung. Eine App im Leerlauf braucht also fast keine CPU.

Hier ist ein winziges, nur gespieltes Immediate-Mode-Toolkit in reinem Rust. Jeder Schleifendurchlauf ist ein Frame. Das Array clicks spielt die Maus:

// Eine gespielte "Ui", die festhält, was in diesem Frame gezeichnet wurde.
struct Ui {
clicked: Option<&'static str>, // der Button, den die Maus in diesem Frame geklickt hat
drawn: Vec<String>,
}
impl Ui {
fn label(&mut self, text: &str) {
self.drawn.push(text.to_string());
}
// Zeichnet einen Button UND sagt dir sofort, ob er geklickt wurde.
fn button(&mut self, text: &'static str) -> bool {
self.drawn.push(format!("[{text}]"));
self.clicked == Some(text)
}
}
// Dein UI-Code: wird in jedem Frame neu aufgerufen.
fn draw(count: &mut i32, ui: &mut Ui) {
ui.label(&format!("Zähler: {count}"));
if ui.button("+") {
*count += 1;
}
if ui.button("Zurücksetzen") {
*count = 0;
}
}
fn main() {
let mut count = 0; // der Zustand der App lebt außerhalb des UI-Codes
let clicks = [None, Some("+"), Some("+"), None, Some("Zurücksetzen"), None];
for (frame, click) in clicks.into_iter().enumerate() {
let mut ui = Ui { clicked: click, drawn: Vec::new() };
draw(&mut count, &mut ui);
println!("Frame {frame}: {}", ui.drawn.join(" "));
}
}

Ausgabe:

Frame 0: Zähler: 0 [+] [Zurücksetzen]
Frame 1: Zähler: 0 [+] [Zurücksetzen]
Frame 2: Zähler: 1 [+] [Zurücksetzen]
Frame 3: Zähler: 2 [+] [Zurücksetzen]
Frame 4: Zähler: 2 [+] [Zurücksetzen]
Frame 5: Zähler: 0 [+] [Zurücksetzen]
▶ Selbst ausprobieren

Schau dir Frame 1 an: Du klickst auf „+“, aber das Label zeigt noch 0. Das Label wurde gezeichnet, bevor der Button den Klick bemerkt hat. Der neue Wert erscheint erst einen Frame später. Das echte egui nimmt dir das ab: Jeder Klick und jeder Tastendruck lässt es sofort den nächsten Frame zeichnen, sodass du die alte Zahl nie siehst.

eframe ist das offizielle „App-Framework“ von egui: Es öffnet das Fenster und lässt egui darin laufen. Leg ein Projekt an und füg eframe hinzu:

Terminal window
cargo new counter
cd counter
[dependencies]
eframe = "0.37"

Ersetz src/main.rs durch Folgendes:

use eframe::egui;
fn main() -> eframe::Result {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default().with_inner_size([300.0, 160.0]),
..Default::default()
};
eframe::run_native(
"Counter",
options,
Box::new(|_cc| Ok(Box::<CounterApp>::default())),
)
}
#[derive(Default)]
struct CounterApp {
count: i32,
}
impl eframe::App for CounterApp {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
egui::CentralPanel::default().show(ui, |ui| {
ui.heading(format!("Count: {}", self.count));
ui.horizontal(|ui| {
if ui.button("-").clicked() {
self.count -= 1;
}
if ui.button("+").clicked() {
self.count += 1;
}
if ui.button("Reset").clicked() {
self.count = 0;
}
});
});
}
}

Starte es mit cargo run. Der erste Build lädt ein paar hundert Crates herunter und kompiliert sie – gib ihm also ein paar Minuten. Spätere Builds sind schnell. Es öffnet sich ein kleines Fenster mit „Count: 0“ und drei Buttons.

So greift alles ineinander:

  • CounterApp ist dein Zustand. #[derive(Default)] lässt count bei 0 starten.
  • run_native öffnet das Fenster. Die Closure erzeugt deine App einmal, verpackt in eine Box (siehe Smart Pointer).
  • eframe ruft ui in jedem Frame auf. CentralPanel füllt das Fenster, und ui.horizontal stellt die drei Buttons in eine Reihe.
  • Jedes if ui.button(..).clicked() ist das Immediate-Mode-Muster aus dem Abschnitt oben: Button zeichnen, abfragen, Zustand ändern.

Genau deshalb muss der Zustand in self stehen, nie in einer lokalen Variable in ui: Ein let mut count = 0; dort würde in jedem Frame wieder bei 0 anfangen.

Eine Tauri-App besteht aus zwei Teilen. Das Frontend ist eine normale Webseite (HTML, CSS und JavaScript oder ein Framework wie React oder Svelte), die in der Webview des Systems läuft. Das Backend ist dein Rust-Code. Das Frontend ruft Rust-Funktionen auf, die Commands heißen.

Am einfachsten startest du mit dem offiziellen Projektgenerator. Er fragt nach einem Namen und einer Frontend-Vorlage (die „vanilla“-Vorlage ohne Framework ist eine gute erste Wahl):

Terminal window
cargo install create-tauri-app --locked
cargo create-tauri-app

Wenn du schon Node.js verwendest, gibt es auch npm create tauri-app@latest. Wechsle danach in den neuen Ordner und starte die App mit npm install und npm run tauri dev (bei den npm-Vorlagen) oder mit cargo install tauri-cli --version "^2.0.0" --locked und cargo tauri dev (bei den reinen Rust-Vorlagen). Das Tool zeigt dir die passenden Befehle für deine Auswahl an.

Die erzeugte Datei src-tauri/src/lib.rs enthält deinen ersten Command:

// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

Und das Frontend (src/main.js in der vanilla-Vorlage, hier gekürzt) ruft ihn so auf:

const { invoke } = window.__TAURI__.core;
greetMsgEl.textContent = await invoke("greet", { name: greetInputEl.value });

Was hier passiert:

  • #[tauri::command] ist ein Attribut-Makro (siehe Prozedurale Makros). Es schreibt den Verbindungscode, der den JavaScript-Aufruf in einen normalen Rust-Aufruf verwandelt.
  • Die Argumente kommen als JSON an. Jeder Argumenttyp muss deshalb Deserialize von serde implementieren, und der Rückgabetyp muss Serialize implementieren (siehe JSON mit Serde). In JavaScript schreibst du Argumentnamen in camelCase: Aus einem Rust-Parameter invoke_message wird { invokeMessage: "..." }.
  • generate_handler![greet] registriert den Command. Vergisst du, einen neuen Command hier einzutragen, schlägt der Aufruf aus JavaScript fehl.
  • Gib ein Result zurück, um Fehler zu melden: Ok erfüllt das JavaScript-Promise, Err lehnt es ab, sodass du es mit .catch() behandeln kannst. Zum Beispiel fn load(path: String) -> Result<String, String>.
  • main.rs ruft nur run() auf. Der Code steht in lib.rs, damit dieselbe App auch für Android und iOS gebaut werden kann.

Slint: die UI in einer eigenen Auszeichnungssprache

Abschnitt betitelt „Slint: die UI in einer eigenen Auszeichnungssprache“

Slint beschreibt den Bildschirm in einer kleinen deklarativen Sprache, ein bisschen wie HTML und CSS zusammen. Dein Rust-Code kümmert sich um die Logik. Hier ist das offizielle Hello World. Trag slint = "1.18" unter [dependencies] ein und schreib das hier in src/main.rs:

slint::slint!{
export component HelloWorld {
Text {
text: "hello world";
color: green;
}
}
}
fn main() {
HelloWorld::new().unwrap().run().unwrap();
}

Das Makro slint! kompiliert das Markup beim Build und erzeugt für dich einen Rust-Typ HelloWorld. In einer echten App kommt das Markup in eine eigene .slint-Datei. Dieser Zähler stammt aus der offiziellen Slint-Vorlage für Rust (auf Englisch). Die Datei ui/app-window.slint:

import { Button, VerticalBox } from "std-widgets.slint";
export component AppWindow inherits Window {
in-out property <int> counter: 42;
callback request-increase-value();
VerticalBox {
Text {
text: "Counter: \{root.counter}";
}
Button {
text: "Increase value";
clicked => {
root.request-increase-value();
}
}
}
}

Und die Rust-Seite in src/main.rs:

slint::include_modules!();
fn main() -> Result<(), Box<dyn std::error::Error>> {
let ui = AppWindow::new()?;
let ui_handle = ui.as_weak();
ui.on_request_increase_value(move || {
let ui = ui_handle.unwrap();
ui.set_counter(ui.get_counter() + 1);
});
ui.run()?;
Ok(())
}

Slint arbeitet im Retained Mode: Die Property counter lebt in der UI, und der Button ruft einen Callback auf. Slint erzeugt get_counter, set_counter und on_request_increase_value aus dem Markup (aus Bindestrichen werden Unterstriche). Die Closure hält einen schwachen Handle (as_weak()), damit sich das Fenster und sein eigener Callback nicht gegenseitig für immer am Leben halten (siehe Weak und Cow). Für eine .slint-Datei brauchst du außerdem slint-build = "1.18" unter [build-dependencies] und eine einzeilige build.rs: slint_build::compile("ui/app-window.slint").expect("Slint build failed");.

Iced folgt der Elm-Architektur, benannt nach der Sprache Elm. Jede App hat vier Teile:

  1. State (Zustand): ein Struct mit allem, was sich die App merkt.
  2. Messages (Nachrichten): ein Enum mit allem, was passieren kann („der +-Button wurde gedrückt“).
  3. update: nimmt den Zustand und eine Nachricht und ändert den Zustand. Das ist die einzige Stelle, an der sich etwas ändert.
  4. view: baut die Widgets aus dem Zustand. Widgets ändern selbst nichts, sie schicken nur Nachrichten.

Hier ist der offizielle Iced-Zähler (füg iced = "0.14" zu [dependencies] hinzu):

use iced::widget::{button, column, text, Column};
use iced::Center;
fn main() -> iced::Result {
iced::run(Counter::update, Counter::view)
}
#[derive(Default)]
struct Counter {
value: i64,
}
#[derive(Debug, Clone, Copy)]
enum Message {
Increment,
Decrement,
}
impl Counter {
fn update(&mut self, message: Message) {
match message {
Message::Increment => self.value += 1,
Message::Decrement => self.value -= 1,
}
}
fn view(&self) -> Column<'_, Message> {
column![
button("Increment").on_press(Message::Increment),
text(self.value).size(50),
button("Decrement").on_press(Message::Decrement),
]
.padding(20)
.align_x(Center)
}
}

on_press(Message::Increment) führt keinen Code aus. Es sagt nur: „Wenn geklickt wird, schick diese Nachricht.“ Iced ruft dann update damit auf und danach wieder view, um den neuen Zustand anzuzeigen. Weil jede Änderung durch ein einziges match läuft, siehst (und testest) du leicht alles, was deine App tun kann. Ein schöner Einsatz für Enums und match.

Um die Idee zu üben, brauchst du Iced gar nicht. Dieses To-do-Modell hat dieselben vier Teile. view gibt Text statt Widgets zurück, und eine Liste von Nachrichten ersetzt das Tippen und Klicken der Nutzerin oder des Nutzers:

// 1. Der Zustand: alles, was sich die App merken muss.
#[derive(Default)]
struct App {
draft: String,
todos: Vec<Todo>,
}
struct Todo {
title: String,
done: bool,
}
// 2. Die Nachrichten: alles, was passieren kann.
#[derive(Debug)]
enum Message {
DraftChanged(String),
AddPressed,
Toggle(usize),
ClearDone,
}
impl App {
// 3. update: die EINZIGE Stelle, an der sich der Zustand ändert.
fn update(&mut self, message: Message) {
match message {
Message::DraftChanged(text) => self.draft = text,
Message::AddPressed => {
let title = self.draft.trim();
if !title.is_empty() {
self.todos.push(Todo { title: title.to_string(), done: false });
}
self.draft.clear();
}
Message::Toggle(index) => {
if let Some(todo) = self.todos.get_mut(index) {
todo.done = !todo.done;
}
}
Message::ClearDone => self.todos.retain(|todo| !todo.done),
}
}
// 4. view: macht aus dem Zustand das, was man sieht (hier: Text).
fn view(&self) -> String {
let mut screen = format!("Neu: [{}]\n", self.draft);
for (index, todo) in self.todos.iter().enumerate() {
let mark = if todo.done { "x" } else { " " };
screen.push_str(&format!("{index}. [{mark}] {}\n", todo.title));
}
screen
}
}
fn main() {
let mut app = App::default();
// Stell dir vor, diese kämen von Tastendrücken und Button-Klicks.
let messages = vec![
Message::DraftChanged("Milch kaufen".to_string()),
Message::AddPressed,
Message::DraftChanged("Bank anrufen".to_string()),
Message::AddPressed,
Message::DraftChanged("Blumen gießen".to_string()),
Message::Toggle(0),
];
for message in messages {
println!("> {message:?}");
app.update(message);
}
print!("{}", app.view());
app.update(Message::ClearDone);
println!("> nach ClearDone:");
print!("{}", app.view());
}

Ausgabe:

> DraftChanged("Milch kaufen")
> AddPressed
> DraftChanged("Bank anrufen")
> AddPressed
> DraftChanged("Blumen gießen")
> Toggle(0)
Neu: [Blumen gießen]
0. [x] Milch kaufen
1. [ ] Bank anrufen
> nach ClearDone:
Neu: [Blumen gießen]
0. [ ] Bank anrufen
▶ Selbst ausprobieren

Fällt dir auf, dass view nie etwas ändert und update nie etwas ausgibt? Diese Trennung ist der ganze Trick. Um daraus eine echte Iced-App zu machen, müsstest du nur view so umschreiben, dass es Widgets zurückgibt (ein text_input, das DraftChanged schickt, und einen button, der AddPressed schickt). Der Zustand, die Nachrichten und update bleiben genau gleich.

GUI-Crates reden mit deinem Betriebssystem. Du brauchst deshalb etwas mehr als nur Rust. Richte zuerst die Grundlagen aus Rust-Werkzeuge und Einrichtung ein. Dann:

Betriebssystem egui/eframe, Iced, Slint Tauri braucht zusätzlich
Windows Nichts außer den MSVC-C++-Tools WebView2. Das ist bereits Teil von Windows 10 (ab Version 1803) und Windows 11
macOS Xcode-Kommandozeilentools (xcode-select --install) Für Desktop-Apps nichts weiter; für iOS das komplette Xcode
Linux (Debian/Ubuntu) Das README von eframe nennt libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev libssl-dev webkit2gtk: libwebkit2gtk-4.1-dev und ein paar weitere Pakete von der Seite mit Tauris Voraussetzungen

Schlägt ein Build unter Linux mit einer Meldung von pkg-config über eine fehlende Bibliothek fehl, installier das -dev-Paket dieser Bibliothek und bau noch einmal. gtk-rs braucht die Entwicklungsdateien von GTK 4 (libgtk-4-dev unter Ubuntu).

  • Den UI-Thread blockieren. Ein Download oder eine lange Berechnung in der ui-Funktion von egui oder in einem update von Iced friert das ganze Fenster ein, bis sie fertig ist. Erledige langsame Arbeit in einem anderen Thread oder als async Task (siehe Nebenläufigkeit in Rust und Async/Await in Rust) und schick das Ergebnis als Nachricht an die UI zurück.
  • egui-Zustand in lokalen Variablen speichern. ui läuft in jedem Frame neu, ein let mut darin fängt also jedes Mal von vorn an. Pack den Zustand in dein App-Struct.
  • Ein Tauri-Command, der nie registriert wird. Jeder Command muss in tauri::generate_handler![...] stehen. Markier Commands in lib.rs außerdem nicht als pub: Laut der Tauri-Doku führt das zu einem E0255-Fehler „defined multiple times“.
  • Einem alten Tutorial folgen. Bei 0.x-Crates kompiliert ein Beispiel vom letzten Jahr vielleicht nicht mehr. Vergleich die Version im Tutorial mit deiner Cargo.toml und schau in den examples-Ordner des Crates für die Version, die du verwendest.

Schreib einen Zähler im Elm-Stil mit den Nachrichten Increment, Decrement und Reset. Der Wert darf nie unter 0 fallen, auch wenn jemand immer wieder auf „-“ drückt. Schick eine Liste von Nachrichten durch update und gib nach jeder einzelnen view() aus.

Tipp: Nimm einen u32 und saturating_sub(1). Das bleibt bei 0 stehen, statt überzulaufen (siehe Integer-Überlauf).

Lösung anzeigen
#[derive(Default)]
struct Counter {
value: u32,
}
#[derive(Debug)]
enum Message {
Increment,
Decrement,
Reset,
}
impl Counter {
fn update(&mut self, message: Message) {
match message {
Message::Increment => self.value += 1,
Message::Decrement => self.value = self.value.saturating_sub(1),
Message::Reset => self.value = 0,
}
}
fn view(&self) -> String {
format!("Zähler: {}", self.value)
}
}
fn main() {
let mut counter = Counter::default();
let messages = [
Message::Increment,
Message::Increment,
Message::Decrement,
Message::Decrement,
Message::Decrement,
Message::Increment,
Message::Reset,
];
for message in messages {
let name = format!("{message:?}");
counter.update(message);
println!("{name:<9} -> {}", counter.view());
}
}

Ausgabe:

Increment -> Zähler: 1
Increment -> Zähler: 2
Decrement -> Zähler: 1
Decrement -> Zähler: 0
Decrement -> Zähler: 0
Increment -> Zähler: 1
Reset -> Zähler: 0
▶ Selbst ausprobieren

Die Regel „nie unter null“ steht an genau einer Stelle: in update. Den Namen der Nachricht formatieren wir vor dem Aufruf von update, denn update nimmt die Nachricht als Wert und verschiebt (moved) sie.

Erweitere deinen Zähler um eine Nachricht Undo. Jede Änderung merkt sich den alten Wert, und Undo geht einen Schritt zurück. Ohne Verlauf tut Undo nichts. Zeig in view an, wie viele Undo-Schritte noch übrig sind.

Tipp: Halte im Zustand einen history: Vec<u32>. Leg vor jeder Änderung den alten Wert mit push ab und hol ihn bei Undo mit pop zurück.

Lösung anzeigen
#[derive(Default)]
struct Counter {
value: u32,
history: Vec<u32>, // ältere Werte, der neueste zuletzt
}
#[derive(Debug)]
enum Message {
Increment,
Decrement,
Reset,
Undo,
}
impl Counter {
fn update(&mut self, message: Message) {
let new_value = match message {
Message::Increment => self.value + 1,
Message::Decrement => self.value.saturating_sub(1),
Message::Reset => 0,
Message::Undo => {
if let Some(previous) = self.history.pop() {
self.value = previous;
}
return;
}
};
self.history.push(self.value); // zuerst den alten Wert merken
self.value = new_value;
}
fn view(&self) -> String {
format!("Zähler: {} (Undo-Schritte: {})", self.value, self.history.len())
}
}
fn main() {
let mut counter = Counter::default();
let messages = [
Message::Increment,
Message::Increment,
Message::Decrement,
Message::Increment,
Message::Reset,
Message::Undo,
Message::Undo,
Message::Undo,
];
for message in messages {
let name = format!("{message:?}");
counter.update(message);
println!("{name:<9} -> {}", counter.view());
}
}

Ausgabe:

Increment -> Zähler: 1 (Undo-Schritte: 1)
Increment -> Zähler: 2 (Undo-Schritte: 2)
Decrement -> Zähler: 1 (Undo-Schritte: 3)
Increment -> Zähler: 2 (Undo-Schritte: 4)
Reset -> Zähler: 0 (Undo-Schritte: 5)
Undo -> Zähler: 2 (Undo-Schritte: 4)
Undo -> Zähler: 1 (Undo-Schritte: 3)
Undo -> Zähler: 2 (Undo-Schritte: 2)
▶ Selbst ausprobieren

Undo war leicht einzubauen, weil jede Änderung ohnehin durch update läuft. In einer echten Iced-App würdest du nur noch einen Button hinzufügen, der Message::Undo schickt.

  • GUI in Rust funktioniert heute gut, aber es gibt noch kein einzelnes Standard-Toolkit, und mehrere beliebte Crates sind noch bei 0.x.
  • Tauri kombiniert ein Web-Frontend mit einem Rust-Backend; egui ist Immediate Mode in reinem Rust; Slint hat eine eigene Auszeichnungssprache und läuft auch auf Embedded-Geräten; Iced nutzt die Elm-Architektur.
  • Der Retained Mode hält einen Baum aus Widgets und ruft deine Callbacks auf. Der Immediate Mode zeichnet die UI in jedem Frame neu aus deinem Zustand, ganz ohne Callbacks.
  • Die Elm-Architektur teilt eine App in Zustand, Nachrichten, update und view. So lassen sich Änderungen leicht nachvollziehen und testen.
  • Tauri braucht WebView2 unter Windows und webkit2gtk unter Linux. Die Grundlagen findest du unter Rust-Werkzeuge und Einrichtung.

Kurzer Check

1. Warum muss der Zählerwert in egui in deinem App-Struct stehen und nicht in einer lokalen Variable in ui?

2. Was macht #[tauri::command] in einer Tauri-App?

3. Wo ändert sich in der Elm-Architektur von Iced der Zustand der App?

4. Was braucht Tauri unter Linux, egui aber nicht?

Punkte: 0 / 4